
Audit Tenant Settings
- 32 installs
- 836 repo stars
- Updated July 29, 2026
- data-goblin/power-bi-agentic-development
Audit Microsoft Fabric and Power BI tenant settings against a baseline, surface drift, enumerate delegated overrides, and investigate the Entra security groups they reference.
About
Audits Fabric/Power BI tenant settings against a curated baseline and surfaces configuration drift and delegated overrides at capacity, domain, and workspace scope. A developer or admin uses it to review tenant settings and the Entra security groups those settings reference.
- Surfaces drift and delegated overrides across scopes
- Requires fabric-cli plugin plus fab and az CLI authentication
Audit Tenant Settings by the numbers
- 32 all-time installs (skills.sh)
- Ranked #1,475 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/data-goblin/power-bi-agentic-development --skill audit-tenant-settingsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 32 |
|---|---|
| repo stars | ★ 836 |
| Last updated | July 29, 2026 |
| Repository | data-goblin/power-bi-agentic-development ↗ |
What it does
Audit Microsoft Fabric and Power BI tenant settings against a baseline, surface drift, enumerate delegated overrides, and investigate the Entra security groups they reference.
Files
Audit Tenant Settings
Audit Fabric / Power BI tenant settings against a curated baseline, surface drift, enumerate delegated overrides at capacity / domain / workspace scope, investigate the Entra security groups those settings reference, and turn findings into a grounded discussion about what to do next. Always invoke the fabric-cli skill alongside this skill; it provides the fab CLI guidance, admin API references, and the microsoft-learn MCP server that this skill depends on.
Prerequisites
This plugin is an add-on to the fabric-cli plugin. It requires:
- fabric-cli plugin installed and enabled; provides
fabCLI guidance, themicrosoft-learnMCP server, and admin API reference docs. - fab CLI (
ms-fabric-cli) authenticated with a Fabric / Power BI admin account. - az CLI authenticated with Graph permissions (
Group.Read.All,User.Read.All,Directory.Read.All,RoleManagement.Read.Directory) when investigating security groups.
Settings
Per-project configuration via .claude/fabric-admin.local.md:
---
enabled: true
tenant_label: "Contoso"
snapshot_path: "~/.cache/fabric-admin-audit/last-snapshot.json"
drift_threshold_high: 5
drift_threshold_medium: 15
notification_level: "info"
schedule: "weekly"
---
# Fabric Admin Configuration
Additional context or tenant-specific notes.| Field | Type | Default | Purpose |
|---|---|---|---|
enabled | bool | true | Toggle the plugin on/off |
tenant_label | string | none | Label for PDF masthead and audit reports |
snapshot_path | string | ~/.cache/fabric-admin-audit/last-snapshot.json | Where to store/read the last-run snapshot JSON |
drift_threshold_high | int | 5 | Alert when high-risk drift count exceeds this |
drift_threshold_medium | int | 15 | Alert when total drift count exceeds this |
notification_level | string | info | Verbosity: quiet, info, verbose |
schedule | string | weekly | Preferred audit cadence: daily, weekly, monthly, ad-hoc |
When to use this skill
Invoke for any tenant-, delegation-, or SG-scoped governance question that needs an interpreted answer rather than a raw API call. Typical asks:
- "Run a tenant governance audit"
- "Has anything drifted since last month?"
- "What does PublishToWeb do and should we have it on?"
- "Which settings are scoped to the PowerBI_ServicePrincipals group and who is in it?"
- "Show me every capacity / domain / workspace override"
- "Is this setting safe to enable for my scenario?"
Critical rules
- Never guess. Tenant-setting behavior, SG membership, and override effects must come from the curated metadata, the live API, or Microsoft Learn. When sources are silent or contradict each other, say so plainly.
- Gather requirements. Use your
AskUserQuestionskill to regularly interview the user about their tenant, user behavior, and adoption. Use thefabric-cliskill andfabto understand an inventory of what's in the tenant, how it's structured, and the activity log / events to understand user adoption and activity. Flag key patterns, anomalies, and high-risk operations or scenarios (like publish-to-web, exports, and sharing with external users, full-org, or C-level employees) - Do not fabricate portal titles, descriptions, recommendations, risk levels, SG membership, or ACL shapes. If the metadata or API lacks the fact, fetch it.
- No absolute compliance claims (HIPAA, SOC 2, GDPR, etc.). Limit conclusions to observed drift against the curated baseline and the raw API findings.
- Recommendations are general, not prescriptive or universal. The curated
recommendedfield reflects subjective and community defaults, not the user's scenario. Always present the nuance and let the user decide. - Be pragmatic and critical; avoid alarmist language. Consider the practicality of the user's scenario and engage with them in decision-making and planning so that they can understand the functional consequences of their current tenant setting configuration:
- What does this mean for governance: what users can do, access, and create?
- How does this affect key processes like creation, sharing, and distribution?
- How does it affect capacity usage in Fabric and resource constraints?
- How does it create friction that could inhibit effective analytics?
- Would enabling or disabling result in blocking users or creating new dependencies?
Authoritative sources (in order)
1. Curated metadata: references/tenant-settings-metadata.yaml. Holds human_name, description, preview, source_url, recommended, risk, and recommendation_nuance for every known setting. Check this first for any tenant-settings question. 2. Live APIs via fab api and az:
- Tenant-wide state:
fab api "admin/tenantsettings" - Delegated overrides:
fab api "admin/capacities/delegatedTenantSettingOverrides",admin/domains/...,admin/workspaces/... - Entra groups and role assignments:
az ad group,az rest --method get --uri https://graph.microsoft.com/v1.0/...
3. Microsoft Learn via the microsoft-learn MCP server (microsoft_docs_search, microsoft_docs_fetch, microsoft_code_sample_search) or the pbi-search CLI as an alternative. Use when metadata is stale, the setting is brand new, or the user asks a feature question the baseline cannot answer.
Workflow
Follow these steps in order. Skip a step only with a clear reason; never silently drop one.
1. Verify prerequisites
fab --versionis current; runuv tool upgrade ms-fabric-cliif stale.fab auth statusconfirms a live session; ask the user to runfab auth loginif not.- Admin access sanity check:
fab api "admin/capacities" 2>&1 | head -5. A 401 / 403 means the account is not a Fabric / Power BI admin; stop and ask the user how to proceed (ask an admin to run it, or pivot to the non-admin scripts). - If any SG investigation will be needed,
az account showshould resolve a session with at leastGroup.Read.All,User.Read.All,Directory.Read.All, andRoleManagement.Read.Directory. Ask foraz loginrather than auto-authenticating.
2. Run the audit script
uv run ${CLAUDE_PLUGIN_ROOT}/skills/audit-tenant-settings/scripts/audit-tenant-settings.py -o /tmp/tenant-audit.mdCommon variants:
--drift-onlyshortens the report to non-compliant settings only.--snapshot /path/to/snap.jsonkeeps per-tenant isolation when auditing more than one tenant.--no-snapshotskips change detection (first runs, or when a clean slate is wanted).
The script merges live state with the curated metadata and computes drift, preview features, SG scoping, and changes since the last snapshot in one pass. Admin write endpoints are rate-limited to 25 requests / minute; honor Retry-After on 429.
For a shareable one-to-two-page briefing, run the PDF generator against the same snapshot:
uv run ${CLAUDE_PLUGIN_ROOT}/skills/audit-tenant-settings/scripts/generate_audit_pdf.py -o /tmp/tenant-audit.pdfThe PDF focuses on headline counts, changes since the last snapshot, the drift table, and a delegated-overrides summary. It reuses the same audit logic as the markdown script (via import) and reads the same snapshot path, so change detection stays in lockstep. Use --no-overrides to skip override enumeration when not running as admin, or --tenant-label "Contoso" to add a tenant name to the masthead. Pair the PDF with the markdown audit; the PDF is for stakeholders, the markdown is for the working walk-through.
3. Review the script output
Read the generated markdown once end-to-end, then surface findings in this order:
1. Headline counts from the Summary section (total, compliant, drift, preview, SG-scoped). 2. Changes since last audit (added, removed, toggled, sg_changed, property_changed). Say "first run" if no snapshot existed. 3. High-risk drift: settings tagged risk: high in the drift table. 4. SG scoping concerns: settings the baseline recommends scoping (on:sg / off:sg) that are currently org-wide, plus any heuristically-flagged individual UPNs. 5. Preview features currently enabled (admins often don't realize they consented to them).
Keep the summary concise (under 400 words). Use portal titles, not API names, in user-facing prose; annotate with (settingName) only where precision matters.
4. Enumerate delegated overrides
Tenant-wide state is half the picture. Any setting whose parent has delegateToCapacity / delegateToDomain / delegateToWorkspace set to true can be replaced by a local override at that scope. Skipping this step is the most common way governance reports go wrong.
Pull all three scopes and tag each override as drift-vs-tenant, drift-vs-recommended, high-risk (parent has risk: high), or orphan (parent does not delegate; override is vestigial). Never silently omit an override, even a compliant one. Render overrides above tenant defaults whenever the user asks about a specific workspace, capacity, or domain, so the effective posture is visible.
Full enumeration patterns, filtering, and change mechanics: references/delegated-overrides.md.
5. Investigate security group setup and strategy
Any setting scoped to a security group is only as strong as the group's membership, ownership, and governance. A recommended scoping that points to an empty or stale SG is effectively no scoping at all. Conversely, a setting restricted to a sprawling, dynamically-populated SG can be less restrictive than leaving it org-wide under a tenant with clean RLS.
Enumerate every graphId referenced by the live tenant settings, resolve each via az ad group, classify members by @odata.type, cross-check against Fabric / Power BI / Global admin role assignments, and feed each finding back onto the corresponding tenant-setting row. Red-flag categories (empty groups, guest members, stale owners, dynamic membership, nested SPs) and the exact Graph queries are in references/security-groups.md.
When the SG strategy itself looks wrong (e.g. one SG reused for unrelated postures, individual users added directly to role-style groups, ownership sitting on departed employees), point it out plainly without alarmist framing. The goal is to help the user rethink the model, not scare them.
6. Drill down on areas of drift
For each setting where drift matters, ground the discussion in authoritative sources before opining:
1. Search the curated metadata by keyword:
grep -i -A6 '<keyword>' ${CLAUDE_PLUGIN_ROOT}/skills/audit-tenant-settings/references/tenant-settings-metadata.yaml2. Confirm the live state:
fab api "admin/tenantsettings" -q "text.tenantSettings[?settingName=='<API name>']"3. If the metadata is stale or lacks nuance for the scenario, fetch the canonical doc via the microsoft-learn MCP server:
microsoft_docs_searchusing the portal title or API namemicrosoft_docs_fetchon the metadata'ssource_urlor the top search hitmicrosoft_code_sample_searchwhen the user wants to see code implications
4. Answer in this shape: portal title + API name, one-sentence description, preview status, current state, recommended posture, risk level, recommendation nuance, docs link.
If metadata and docs disagree, trust the docs and surface the drift to the user so the baseline can be updated.
7. Present findings and open a grounded discussion
Present results objectively. Avoid alarmist language. A setting that drifts from the baseline is not automatically wrong; baselines are general, the user's scenario may differ. Equally, a setting that matches the baseline may still be wrong for the user's particular organization.
For every area of drift, pair two halves of the conversation:
- Drift against recommended: settings currently non-compliant with the curated baseline. Explain what changing them would do, what features break, what becomes possible.
- Recommended but wrong for this user: settings that match the baseline but might need to change based on the user's scenario (for example, a self-service org that needs
PublishToWebenabled selectively, or a regulated tenant that needsServicePrincipalsUseReadAdminAPIstighter than the general recommendation).
Ask short, targeted questions about the user's scenario when it matters for the recommendation: licensing model (Pro, PPU, Premium, Fabric), content lifecycle (self-service vs enterprise), regulatory posture, existing SG strategy, in-flight adoption goals. Do not interview the user about things that do not affect the recommendation.
8. Formulate next steps with the user
Co-develop a plan rather than handing one down. Candidates include:
- Documentation: capture current posture, SG membership, override inventory in a runbook before changing anything.
- Security-group remediation: create or consolidate SGs, allocate users/SPs, retire empty or stale groups, move ownership off departed employees.
- Controlled tenant-setting changes: pilot a subset of users via SG scoping first, then broaden. Always via the portal or the explicit
fab api -X postcommand; never auto-applied from this skill. - Capacity / domain / workspace override audit: walk the overrides list with the user and decide which are intentional and which should be removed.
- Adoption-planning, implementation-planning, and security-and-compliance-planning touchpoints from the Power BI implementation planning series. Point to the relevant articles rather than reproducing the content.
- Scheduled snapshots and alerting: when the user wants ongoing oversight rather than ad-hoc reviews, offer to set up a recurring run of the audit script (for example via
cron, a scheduled GitHub Action, a Fabric notebook on a schedule, or an Azure DevOps pipeline) that refreshes the snapshot and ships the resulting diff somewhere visible. From there, change-detection output can feed an alerting surface; Fabric Activator is one option, but Teams or email via Power Automate, a pager via webhook, or a simple inbox rule all work. Use this to catch new settings Microsoft adds, posture drift, and SG membership changes without having to rerun the audit manually. Scope and wire-up are the user's call; the skill can help design the flow but should not stand anything up without explicit approval.
Close every plan with the disclaimer: "These recommendations are based on the curated baseline and the live API state at the time of this audit. The agent may not present fully accurate or scenario-appropriate information; the user is responsible for due diligence, piloting changes, and confirming with their own security, compliance, and Fabric administration teams before applying anything in production."
Output standards
- Use portal titles (the
human_namefrom metadata) in user-facing text; annotate with(settingName)only when precision matters or the API name is more recognizable. - Summaries with counts render as ASCII tables or aligned bullet lists. No emojis, no decorative formatting.
- Quote descriptions and recommendations from metadata and docs verbatim; never paraphrase in a way that changes meaning.
- Full audit summaries under 400 words. Single-setting questions under 200 words. When a user asks for a binary "should we" on a setting whose
recommendedfield isreview, explicitly say the baseline has no hard position and present the nuance. - Never expose full SG membership or full workspace user lists unless the user explicitly asks. Summarize with counts, role breakdowns, and named red flags.
Edge cases
- Unknown setting in live API: Microsoft added it since the last metadata refresh. Fetch the Learn entry for context and offer to extend
tenant-settings-metadata.yaml. - Metadata entry missing from live API: setting was renamed or retired. Do not produce a recommendation.
- Ambiguous setting phrasing: if more than one setting matches the query, list candidates and ask which one.
- No `fab auth` session: ask the user to run
fab auth loginbefore proceeding. Do not auto-authenticate. - No `az login` when an SG question comes up: ask for
az login. Offer to fall back to the script's heuristic UPN flag for a first-pass smoke test. - Rate limit (429): back off per
Retry-After. When resuming, resume from the failed setting rather than restarting. - Huge tenants: admin workspace and items APIs are paged. Use
continuationUri/continuationTokenand stream; do not try to materialize everything into a single response.
Resources
Bundled
references/tenant-settings-metadata.yaml; curated baseline for every known Fabric / Power BI tenant setting (portal title, description, preview, recommendation, risk, nuance, docs link).references/delegated-overrides.md; enumerate, classify, and (for capacity only) change delegated overrides.references/security-groups.md; resolve graphIds, classify members, detect red flags, cross-check admin role assignments.scripts/audit-tenant-settings.py; audit + change-detection script. Consumes the metadata yaml via its siblingreferences/path.scripts/generate_audit_pdf.py; renders a clean one-to-two-page PDF briefing of the same audit. Reuses the audit logic by importing the sibling script, optionally enumerates delegated overrides, and emits a compact editorial-style summary with headline counts, changes since last audit, a drift table, and a delegated-overrides section. Run withuv run scripts/generate_audit_pdf.py -o /tmp/tenant-audit.pdfafter (or instead of) the markdown audit; share the PDF with stakeholders, keep the markdown for the working walk-through.
Related reading in the main fabric-cli skill
plugins/fabric-cli/skills/fabric-cli/references/admin.md; raw admin API mechanics for settings updates, paging, and activity events.plugins/fabric-cli/skills/fabric-cli/references/permissions.md; workspace and item ACL workflows for the cross-domain half of a governance audit.plugins/fabric-cli/skills/fabric-cli/SKILL.md; entry point and command reference for thefabCLI.
External
- Microsoft Learn tenant settings index ; authoritative upstream for every setting the script can surface.
- Power BI implementation planning: security and compliance planning ; pair with findings during step 8.
Delegated Overrides
Any tenant setting whose parent has delegateToCapacity, delegateToDomain, or delegateToWorkspace set to true can be replaced by a local override at that scope. The effective posture at a given workspace is: the tenant-wide setting, unless an override applies, in which case the override wins. Every audit has to enumerate overrides before drawing conclusions; skipping this step is the most common source of wrong governance reports.
Enumerate every override, tenant-wide
fab api "admin/capacities/delegatedTenantSettingOverrides"
fab api "admin/domains/delegatedTenantSettingOverrides"
fab api "admin/workspaces/delegatedTenantSettingOverrides"The capacity response ships both overrides and value arrays (legacy + current schema); domain and workspace ship only value. All three page via continuationUri / continuationToken; stream results for large tenants instead of materialising them all in memory.
Scoped enumeration
Only capacity has a per-id endpoint. Per-domain and per-workspace variants return 404 and must be filtered client-side from the tenant-wide list.
# Per-capacity (native scoped endpoint)
fab api "admin/capacities/{capacityId}/delegatedTenantSettingOverrides"
# Per-domain (filter the tenant-wide list)
fab api "admin/domains/delegatedTenantSettingOverrides" \
-q "text.value[?id=='{domainId}']"
# Per-workspace (filter the tenant-wide list)
fab api "admin/workspaces/delegatedTenantSettingOverrides" \
-q "text.value[?id=='{workspaceId}']"Classification
Tag each override as one of the following before reporting. Never silently omit a compliant override; the user still needs to see effective posture at every scope.
| Tag | Meaning |
|---|---|
drift-vs-tenant | Override differs from the tenant-wide posture. The intended case for overrides, but still worth surfacing so the admin sees where scope behavior diverges. |
drift-vs-recommended | Override differs from the recommended posture in the curated baseline. Treat with the same severity as a tenant-wide drift for the same setting. |
high-risk | The parent setting has risk: high in the metadata, regardless of direction. Surface these at the top of the overrides section. |
orphan | Override exists for a setting whose parent does NOT delegate. It may not be taking effect but is worth cleaning up. |
Rendering in audit output
Always include an "Overrides" section alongside the tenant-settings drift table. When the user asks about a specific workspace, capacity, or domain, render that scope's overrides above the tenant defaults so the effective posture is visible. For each override, show:
1. Parent setting portal title and API name 2. Scope (capacity / domain / workspace) and the ID or name 3. Override enabled vs parent tenant enabled 4. Override enabledSecurityGroups / excludedSecurityGroups / properties vs parent 5. Classification tag(s) 6. Recommended posture for the parent setting, pulled from the metadata
Changing overrides
The Fabric admin REST API only exposes Update and Delete for capacity overrides. Domain and workspace overrides are read-only through the API and must be changed in the admin portal UI (Admin portal → Capacity / Domain / Workspace settings) by someone with the appropriate role.
When a user asks to change a domain or workspace override, explain that there is no API path, print the portal navigation, and stop. For capacity overrides the API path is available but still must not run without explicit confirmation.
# Update a capacity override (same body shape as the tenant-wide update endpoint)
cat > /tmp/override.json <<'EOF'
{
"enabled": true,
"properties": [
{"name": "ArtifactRetentionPeriod", "value": "14", "type": "Integer"}
]
}
EOF
fab api -X post \
"admin/capacities/{capacityId}/delegatedTenantSettingOverrides/{settingName}/update" \
-i /tmp/override.json
# Remove a capacity override (reverts the scope to the parent tenant setting)
fab api -X delete \
"admin/capacities/{capacityId}/delegatedTenantSettingOverrides/{settingName}"Never run either command without explicit user confirmation. Print the command, explain what it will change, and wait.
Common gotchas
- Pagination. All three endpoints can span thousands of overrides on big tenants. Always follow
continuationUriuntil it's empty. - `value` vs `overrides`. Capacity responses can include both arrays;
overridesis the legacy shape. Readvaluefirst and fall back tooverridesif the newer shape is empty. - Property type mismatches. Update bodies have to match the property type declared on the parent setting. Pulling the parent metadata before patching the override avoids 400s on type coercion.
- Orphan overrides survive metadata churn. A setting may have had delegation turned off by Microsoft while historical overrides linger. Flag them, don't silently drop them.
- Rate limit. Admin write endpoints cap at 25 requests / minute. When scripting capacity updates, respect
Retry-Afterand batch by setting, not by capacity, so a single backoff doesn't stall the whole pass.
Security Groups Behind Tenant Settings
Tenant-setting scoping, certification and endorsement, guest access, service-principal admin permissions, and most distribution postures are only as strong as the Entra security groups they point at. A stale, empty, or over-permissioned SG silently weakens every setting that references it.
This reference covers the four moves needed to audit SGs from a Fabric governance perspective: enumerate the graphIds in use, resolve each group, flag red-flag patterns, and cross-check admin role assignments. Feed everything back into the tenant-settings report so the admin sees the full picture per setting.
1. Enumerate every SG referenced across tenant settings
fab api "admin/tenantsettings" --output_format json 2>/dev/null \
| jq -r '.result.data[0].text.tenantSettings[]
| (.enabledSecurityGroups // []) + (.excludedSecurityGroups // [])
| .[] | "\(.graphId)\t\(.name)"' \
| sort -uFor each graphId, track which settings reference it. The same SG often shows up across unrelated settings; that's an important signal by itself (see red flags below).
2. Resolve each group via Azure CLI
az ad group show --group <graphId> # existence + metadata
az ad group member list --group <graphId> --all # direct members (users, SPs, nested groups)
az ad group owner list --group <graphId> # owners (can add/remove members)Classify members by @odata.type:
#microsoft.graph.user; human account. ReaduserPrincipalName.#EXT#in the UPN marks external guests.#microsoft.graph.servicePrincipal; app identity. Pair withaz ad sp showfor app metadata.#microsoft.graph.group; nested group. Recurse so final membership is flattened.
For dynamic groups, the membership rule itself is part of the audit:
az ad group show --group <graphId> --query membershipRule3. Red flags to report
Surface findings per SG with a short rationale and the list of tenant settings affected. Avoid alarmist framing; state what the situation is and what posture depends on it.
- Empty groups referenced by a critical setting (e.g.
AllowServicePrincipalsUseReadAdminAPIs). Scoping is meaningless when no one is in. - Individual users added directly where the intent was role-based. Churn and offboarding risk; SG becomes a personal allowlist.
- Guest users (
#EXT#in UPN) inside SGs scoping write-capable admin settings. External accounts with tenant-admin reach are almost always a misconfiguration. - Nested groups with owners outside IT who can silently grant themselves or others Fabric access.
- Dynamic membership groups with liberal rules (broad
userType,department, or tag-based filters) used to scope narrow admin capabilities. - Service principals with elevated Graph permissions nested into a tenant-setting SG without an obvious owning automation.
- Stale groups whose membership hasn't changed in months. Compare
createdDateTime/renewedDateTimeonaz ad group show. - Groups owned by departed employees or with no owner at all. Nobody can responsibly curate them.
- One SG scoping unrelated postures. A single "PowerBI_Admins" group used for
PublishToWeb,AllowServicePrincipalsUseReadAdminAPIs, andEnableExternalDataSharingis probably not the intended model; each setting usually deserves a dedicated SG so changes don't cascade unintentionally.
4. Cross-check Entra role assignments
Members of Fabric, Power BI, or Global admin roles bypass most tenant settings. Audit active assignments so SG-scoped postures aren't undermined by role-level access:
# Fabric Administrator role (well-known role template ID)
az rest --method get \
--uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$filter=roleDefinitionId eq 'a9ea8996-122f-4c74-9520-8edcd192826c'&\$expand=principal"
# Global Administrator role (well-known role template ID)
az rest --method get \
--uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'&\$expand=principal"If the tenant uses Privileged Identity Management (PIM), also check roleEligibilitySchedules; eligibility and active assignment are separate, and PIM-eligible principals still count for bypass calculations once they activate.
5. Feed back into the tenant-settings report
After resolving groups, re-read the audit-tenant-settings.py output. For each SG-scoped entry, attach:
- Member count (total, users, SPs, guests)
- Owner count
- Red-flag list (empty / stale / dynamic / guest / nested-SP / unowned)
- The tenant settings that also reference this SG (so the admin sees the blast radius of any membership change)
Prerequisites for SG work
az loginwith at leastGroup.Read.All,User.Read.All,Directory.Read.All, andRoleManagement.Read.Directoryscopes. Verify withaz account show.- For PIM queries, also
PrivilegedAccess.Read.AzureAD. - Ask the user to run
az loginthemselves; don't auto-authenticate.
Output standards for SG findings
- Summarize membership with counts, never full member lists unless the user explicitly asks.
- When presenting red flags, name the group, state the SG's current membership profile, and list which tenant settings reference it.
- Do not produce compliance claims ("this SG violates SOC 2"); state the facts and let the user draw the compliance conclusion with their own team.
- When a red flag depends on an assumption (e.g. "stale groups haven't changed in months"), say so explicitly; the metric is heuristic, not absolute.
#!/usr/bin/env python3
"""Audit Fabric tenant settings against a curated recommendation baseline.
Fetches live tenant settings via `fab api admin/tenantsettings`, merges them
with the metadata in `references/tenant-settings-metadata.yaml`, and renders
a markdown report grouped by admin-portal section. For each setting it shows
the live state directly above the recommended posture so an admin can audit
drift, preview features, and security-group scoping at a glance.
Usage:
uv run scripts/audit-tenant-settings.py # print to stdout
uv run scripts/audit-tenant-settings.py -o audit.md # write to file
uv run scripts/audit-tenant-settings.py --drift-only # only non-compliant
Requires: fab CLI, pyyaml.
"""
# region Imports
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
# endregion
# region Constants
SCRIPT_DIR = Path(__file__).resolve().parent
METADATA_PATH = SCRIPT_DIR.parent / "references" / "tenant-settings-metadata.yaml"
INDEX_URL = "https://learn.microsoft.com/en-us/fabric/admin/tenant-settings-index"
DEFAULT_SNAPSHOT = Path.home() / ".cache" / "fabric-admin-audit" / "last-snapshot.json"
# endregion
# region Data classes
@dataclass
class SettingRecord:
api_name: str
group: str
human_name: str
description: str
preview: bool
source_url: str
recommended: str | None
default: str | None
default_source: str | None
default_properties: dict[str, str] | None
risk: str | None
recommendation_nuance: str | None
needs_review: bool
live_enabled: bool
can_specify_sg: bool
enabled_sg: list[dict[str, str]]
excluded_sg: list[dict[str, str]]
properties: list[dict[str, Any]]
# endregion
# region IO helpers
def fetch_live_settings() -> list[dict[str, Any]]:
"""Call `fab api admin/tenantsettings` and return the list of settings.
Fails with a non-zero exit code if the fab CLI is unauthenticated or the
admin endpoint is unreachable.
"""
result = subprocess.run(
["fab", "api", "admin/tenantsettings", "--output_format", "json"],
capture_output=True,
text=True,
)
if result.returncode != 0:
sys.exit(f"fab api failed (exit {result.returncode}):\n{result.stderr}")
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
sys.exit(f"could not parse fab api output as JSON: {exc}")
try:
return payload["result"]["data"][0]["text"]["tenantSettings"]
except (KeyError, IndexError) as exc:
sys.exit(f"unexpected fab api payload shape: {exc}")
def load_snapshot(path: Path) -> dict[str, Any] | None:
"""Load the previous audit snapshot if it exists.
Returns None when the file doesn't exist or can't be parsed; callers treat
that as "no baseline available" and skip the change-detection section.
"""
if not path.exists():
return None
try:
return json.loads(path.read_text())
except json.JSONDecodeError:
return None
def save_snapshot(path: Path, live: list[dict[str, Any]]) -> None:
"""Persist the current live settings so the next run can diff against it."""
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"settings": {s["settingName"]: normalize_for_snapshot(s) for s in live},
}
path.write_text(json.dumps(payload, indent=2, sort_keys=True))
def normalize_for_snapshot(setting: dict[str, Any]) -> dict[str, Any]:
"""Reduce a live setting to the fields that matter for change detection.
Uses graphId (not name) for group identity so a rename doesn't look like
a config change, and sorts lists so ordering noise doesn't create false
positives.
"""
return {
"title": setting.get("title", ""),
"group": setting.get("tenantSettingGroup", ""),
"enabled": bool(setting.get("enabled", False)),
"canSpecifySecurityGroups": bool(setting.get("canSpecifySecurityGroups", False)),
"enabledSecurityGroups": sorted(
[sg.get("graphId", "") for sg in setting.get("enabledSecurityGroups") or []]
),
"excludedSecurityGroups": sorted(
[sg.get("graphId", "") for sg in setting.get("excludedSecurityGroups") or []]
),
"properties": sorted(
[
{"name": p.get("name", ""), "value": p.get("value", ""), "type": p.get("type", "")}
for p in setting.get("properties") or []
],
key=lambda p: p["name"],
),
}
def diff_snapshots(previous: dict[str, Any], current: list[dict[str, Any]]) -> dict[str, list[Any]]:
"""Compare the previous snapshot against the current live state.
Returns a dict with five lists: added, removed, toggled, sg_changed,
property_changed. Each entry is a small struct the renderer knows how
to format.
"""
prev_settings = previous.get("settings", {})
current_map = {s["settingName"]: normalize_for_snapshot(s) for s in current}
added = sorted(set(current_map) - set(prev_settings))
removed = sorted(set(prev_settings) - set(current_map))
toggled: list[dict[str, Any]] = []
sg_changed: list[dict[str, Any]] = []
property_changed: list[dict[str, Any]] = []
for name in sorted(set(prev_settings) & set(current_map)):
prev_entry = prev_settings[name]
curr_entry = current_map[name]
if prev_entry.get("enabled") != curr_entry.get("enabled"):
toggled.append(
{
"name": name,
"title": curr_entry.get("title") or prev_entry.get("title"),
"from": "on" if prev_entry.get("enabled") else "off",
"to": "on" if curr_entry.get("enabled") else "off",
}
)
if (
prev_entry.get("enabledSecurityGroups") != curr_entry.get("enabledSecurityGroups")
or prev_entry.get("excludedSecurityGroups") != curr_entry.get("excludedSecurityGroups")
):
sg_changed.append(
{
"name": name,
"title": curr_entry.get("title") or prev_entry.get("title"),
"prev_enabled_sg": prev_entry.get("enabledSecurityGroups", []),
"curr_enabled_sg": curr_entry.get("enabledSecurityGroups", []),
"prev_excluded_sg": prev_entry.get("excludedSecurityGroups", []),
"curr_excluded_sg": curr_entry.get("excludedSecurityGroups", []),
}
)
if prev_entry.get("properties") != curr_entry.get("properties"):
property_changed.append(
{
"name": name,
"title": curr_entry.get("title") or prev_entry.get("title"),
"prev": prev_entry.get("properties", []),
"curr": curr_entry.get("properties", []),
}
)
return {
"added": added,
"removed": removed,
"toggled": toggled,
"sg_changed": sg_changed,
"property_changed": property_changed,
}
def load_metadata(path: Path) -> dict[str, dict[str, Any]]:
"""Load the curated metadata file.
The YAML file is the single source of truth for every field (human_name,
description, recommended, default, etc.). Every `recommended` value must
be one of the quoted strings "on", "off", "on:sg", or null; bare on/off
would be coerced to booleans by YAML 1.1 and is rejected so the schema
stays obvious when read by a human.
"""
if not path.exists():
sys.exit(f"metadata file not found: {path}")
with path.open() as f:
data = yaml.safe_load(f) or {}
allowed = {"on", "off", "on:sg", None}
for name, entry in data.items():
rec = entry.get("recommended")
if rec not in allowed:
sys.exit(
f"metadata: {name} has invalid recommended={rec!r}; "
f"must be one of \"on\", \"off\", \"on:sg\", or null"
)
return data
# endregion
# region Merge
def merge(metadata: dict[str, dict[str, Any]], live: list[dict[str, Any]]) -> tuple[list[SettingRecord], list[str], list[str]]:
"""Merge live API state with curated metadata.
Returns (records, unknown_live, missing_live) where unknown_live are
settings present in the API but not in the metadata, and missing_live
are settings in the metadata but absent from the API response.
"""
live_by_name = {s["settingName"]: s for s in live}
records: list[SettingRecord] = []
unknown_live = sorted(set(live_by_name) - set(metadata))
missing_live = sorted(set(metadata) - set(live_by_name))
for api_name in sorted(metadata):
meta_entry = metadata[api_name]
live_entry = live_by_name.get(api_name, {})
records.append(
SettingRecord(
api_name=api_name,
group=live_entry.get("tenantSettingGroup", "(unknown group)"),
human_name=meta_entry.get("human_name", api_name),
description=meta_entry.get("description", ""),
preview=bool(meta_entry.get("preview", False)),
source_url=meta_entry.get("source_url", INDEX_URL),
recommended=meta_entry.get("recommended"),
default=meta_entry.get("default"),
default_source=meta_entry.get("default_source"),
default_properties=meta_entry.get("default_properties"),
risk=meta_entry.get("risk"),
recommendation_nuance=meta_entry.get("recommendation_nuance"),
needs_review=bool(meta_entry.get("needs_review", False)),
live_enabled=bool(live_entry.get("enabled", False)),
can_specify_sg=bool(live_entry.get("canSpecifySecurityGroups", False)),
enabled_sg=live_entry.get("enabledSecurityGroups") or [],
excluded_sg=live_entry.get("excludedSecurityGroups") or [],
properties=live_entry.get("properties") or [],
)
)
return records, unknown_live, missing_live
# endregion
# region Status calculation
def current_state(record: SettingRecord) -> str:
"""Normalize the live state to the recommendation vocabulary.
Live state collapses into one of the three valid postures: off, on, on:sg.
Any SG membership at all (enabled_sg or excluded_sg) counts as on:sg.
"""
if not record.live_enabled:
return "off"
if record.enabled_sg or record.excluded_sg:
return "on:sg"
return "on"
def current_detail(record: SettingRecord) -> str:
"""Render the SG membership detail as a short parenthetical suffix."""
bits: list[str] = []
if record.enabled_sg:
names = ", ".join(sg.get("name", sg.get("graphId", "?")) for sg in record.enabled_sg)
bits.append(f"{len(record.enabled_sg)} enabled SG: {names}")
if record.excluded_sg:
names = ", ".join(sg.get("name", sg.get("graphId", "?")) for sg in record.excluded_sg)
bits.append(f"{len(record.excluded_sg)} excluded SG: {names}")
return f" ({'; '.join(bits)})" if bits else ""
def current_label(record: SettingRecord) -> str:
"""Render Current state as `<normalized>[ (detail)]`, matching metadata format."""
return f"{current_state(record)}{current_detail(record)}"
def status_of(record: SettingRecord) -> str:
"""Compare recommended vs live to label compliance.
The recommendation vocabulary is closed: on, off, on:sg. Anything else
(including None) is treated as unknown so metadata bugs surface instead
of silently masquerading as compliant.
"""
rec = record.recommended
if rec is None:
return "unknown"
has_sg_scope = bool(record.enabled_sg) or bool(record.excluded_sg)
if rec == "on":
return "compliant" if (record.live_enabled and not has_sg_scope) else "drift"
if rec == "off":
return "compliant" if not record.live_enabled else "drift"
if rec == "on:sg":
return "compliant" if (record.live_enabled and has_sg_scope) else "drift"
return "unknown"
UPN_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
def individuals_in_scope(record: SettingRecord) -> list[str]:
"""Heuristic: any SG entry whose `name` looks like a UPN email is probably an individual.
The tenant-settings API schema only exposes security groups, but some
admin-portal flows end up naming individual users. We can only detect this
via a Microsoft Graph lookup on graphId, which this script doesn't do, so
we fall back to flagging anything that looks like a UPN.
"""
flagged: list[str] = []
for sg in record.enabled_sg + record.excluded_sg:
name = sg.get("name", "")
if UPN_RE.search(name):
flagged.append(name)
return flagged
# endregion
# region Markdown rendering
STATUS_SYMBOL = {
"compliant": "OK",
"drift": "DRIFT",
"unknown": "UNKNOWN",
}
def render_summary(records: list[SettingRecord], unknown_live: list[str], missing_live: list[str]) -> str:
total = len(records)
compliant = sum(1 for r in records if status_of(r) == "compliant")
drift = sum(1 for r in records if status_of(r) == "drift")
unknown = sum(1 for r in records if status_of(r) == "unknown")
preview = sum(1 for r in records if r.preview)
sg_scoped = sum(1 for r in records if (r.enabled_sg or r.excluded_sg))
individuals = sum(1 for r in records if individuals_in_scope(r))
needs_review = sum(1 for r in records if r.needs_review)
with_default = sum(1 for r in records if r.default is not None)
differs_from_default = sum(
1 for r in records
if r.default is not None and current_state(r) != r.default
)
lines = [
"## Summary",
"",
f"- Total settings: **{total}**",
f"- Compliant: **{compliant}**",
f"- Drift vs. recommended: **{drift}**",
f"- Preview features: **{preview}**",
f"- SG-scoped: **{sg_scoped}**",
f"- Flagged individuals: **{individuals}**",
f"- With documented default: **{with_default}** / {total}",
f"- Differ from default: **{differs_from_default}** (of {with_default} with documented default)",
]
if unknown:
lines.append(f"- Unknown (missing recommendation in metadata): **{unknown}**")
if needs_review:
lines.append(f"- Metadata gaps (needs_review): **{needs_review}**")
if unknown_live:
lines.append(f"- Live settings missing from metadata: **{len(unknown_live)}**")
if missing_live:
lines.append(f"- Metadata entries missing from live API: **{len(missing_live)}**")
lines.append("")
return "\n".join(lines)
def render_drift_table(records: list[SettingRecord]) -> str:
drift_records = [r for r in records if status_of(r) == "drift"]
if not drift_records:
return "## Drift\n\nNo drift detected against the recommendation baseline.\n"
# sort: high risk first, then by group
risk_order = {"high": 0, "medium": 1, "low": 2, None: 3}
drift_records.sort(key=lambda r: (risk_order.get(r.risk, 3), r.group, r.human_name))
lines = [
"## Drift",
"",
f"{len(drift_records)} settings differ from the recommended posture.",
"",
"| Risk | Group | Setting | Current | Recommended | Default |",
"|---|---|---|---|---|---|",
]
for r in drift_records:
default_cell = r.default if r.default is not None else "-"
lines.append(
f"| {r.risk or '-'} | {r.group} | **{r.human_name}** (`{r.api_name}`) "
f"| {current_state(r)} | {r.recommended} | {default_cell} |"
)
lines.append("")
return "\n".join(lines)
def render_individuals_table(records: list[SettingRecord]) -> str:
flagged = [(r, individuals_in_scope(r)) for r in records if individuals_in_scope(r)]
if not flagged:
return ""
lines = [
"## Individuals in SG scoping (heuristic)",
"",
"Entries below have at least one security-group member whose name looks like a UPN (`user@domain`). "
"The tenant-settings API schema only exposes security groups, so this is a heuristic check; "
"confirm against Microsoft Graph before acting.",
"",
"| Setting | Principal(s) |",
"|---|---|",
]
for r, principals in flagged:
lines.append(f"| **{r.human_name}** (`{r.api_name}`) | {', '.join(principals)} |")
lines.append("")
return "\n".join(lines)
def render_setting(record: SettingRecord) -> str:
"""Render one setting block with Current / Recommended / Default adjacent.
All three lines use the same on / off / on:sg vocabulary so they line up
visually. Current also includes an SG-membership detail in parentheses
when the live state is SG-scoped.
"""
status = status_of(record)
header = f"#### {record.human_name}"
meta_line_parts = [f"`{record.api_name}`"]
if record.preview:
meta_line_parts.append("preview")
if record.risk:
meta_line_parts.append(f"risk: **{record.risk}**")
if record.needs_review:
meta_line_parts.append("**needs_review**")
default_value = record.default if record.default is not None else "(not documented)"
recommended_value = record.recommended if record.recommended is not None else "(none)"
lines = [
header,
"",
" · ".join(meta_line_parts),
"",
record.description,
"",
f"Docs: <{record.source_url}>",
"",
"```",
f"Current: {current_label(record)}",
f"Recommended: {recommended_value}",
f"Default: {default_value}",
f"Status: {STATUS_SYMBOL.get(status, status)}",
"```",
]
if record.default_properties:
lines.append("")
lines.append("Default properties:")
for name, value in record.default_properties.items():
lines.append(f"- `{name}` = `{value}`")
# Only cite the default source when we actually have a documented default;
# for unverified entries the default_source URL points at the doc that
# didn't state a default, so citing it would be misleading.
if record.default is not None and record.default_source and record.default_source != record.source_url:
lines.append("")
lines.append(f"Default source: <{record.default_source}>")
props = [p for p in record.properties if p.get("value")]
if props:
lines.append("")
lines.append("Current properties:")
for p in props:
lines.append(f"- `{p.get('name')}` = `{p.get('value')}` ({p.get('type')})")
individuals = individuals_in_scope(record)
if individuals:
lines.append("")
lines.append(f"> Individuals in SG scope: {', '.join(individuals)}")
if record.recommendation_nuance:
lines.append("")
lines.append(f"_Nuance:_ {record.recommendation_nuance}")
lines.append("")
return "\n".join(lines)
def render_full_audit(records: list[SettingRecord], drift_only: bool) -> str:
by_group: dict[str, list[SettingRecord]] = {}
for r in records:
if drift_only and status_of(r) != "drift":
continue
by_group.setdefault(r.group, []).append(r)
if not by_group:
return "## Full Audit\n\nNothing to render (drift-only mode and no drift detected).\n"
lines = ["## Full Audit", ""]
for group in sorted(by_group):
lines.append(f"### {group}")
lines.append("")
for r in sorted(by_group[group], key=lambda x: x.human_name):
lines.append(render_setting(r))
return "\n".join(lines)
def render_changes(diff: dict[str, list[Any]] | None, previous_timestamp: str | None) -> str:
"""Render the "Changes since last audit" section.
Returns an empty string when there's no snapshot to compare against so
the report doesn't render an empty header on first-ever runs.
"""
if diff is None:
return (
"## Changes since last audit\n\n"
"No previous snapshot found; this is a baseline run. Re-run the audit later "
"to see drift, added settings, and toggled states.\n"
)
total = sum(len(v) for v in diff.values())
if total == 0:
return (
"## Changes since last audit\n\n"
f"No changes since the last snapshot (`{previous_timestamp or 'unknown timestamp'}`).\n"
)
lines = [
"## Changes since last audit",
"",
f"{total} change(s) since the last snapshot (`{previous_timestamp or 'unknown timestamp'}`).",
"",
]
if diff["added"]:
lines.append(f"### Added settings ({len(diff['added'])})")
lines.append("")
lines.append("Microsoft added these settings since the last run; consider updating the curated metadata.")
lines.append("")
for name in diff["added"]:
lines.append(f"- `{name}`")
lines.append("")
if diff["removed"]:
lines.append(f"### Removed settings ({len(diff['removed'])})")
lines.append("")
lines.append("These were in the previous snapshot but no longer come back from the API.")
lines.append("")
for name in diff["removed"]:
lines.append(f"- `{name}`")
lines.append("")
if diff["toggled"]:
lines.append(f"### Toggled on/off ({len(diff['toggled'])})")
lines.append("")
lines.append("| Setting | From | To |")
lines.append("|---|---|---|")
for entry in diff["toggled"]:
title = entry.get("title") or entry["name"]
lines.append(f"| **{title}** (`{entry['name']}`) | {entry['from']} | {entry['to']} |")
lines.append("")
if diff["sg_changed"]:
lines.append(f"### Security-group scope changes ({len(diff['sg_changed'])})")
lines.append("")
lines.append("| Setting | Enabled SG (before → after) | Excluded SG (before → after) |")
lines.append("|---|---|---|")
for entry in diff["sg_changed"]:
title = entry.get("title") or entry["name"]
before_e = len(entry["prev_enabled_sg"])
after_e = len(entry["curr_enabled_sg"])
before_x = len(entry["prev_excluded_sg"])
after_x = len(entry["curr_excluded_sg"])
lines.append(
f"| **{title}** (`{entry['name']}`) | {before_e} → {after_e} | {before_x} → {after_x} |"
)
lines.append("")
if diff["property_changed"]:
lines.append(f"### Property value changes ({len(diff['property_changed'])})")
lines.append("")
for entry in diff["property_changed"]:
title = entry.get("title") or entry["name"]
lines.append(f"- **{title}** (`{entry['name']}`)")
prev_props = {p["name"]: p["value"] for p in entry["prev"]}
curr_props = {p["name"]: p["value"] for p in entry["curr"]}
for key in sorted(set(prev_props) | set(curr_props)):
before = prev_props.get(key, "(absent)")
after = curr_props.get(key, "(absent)")
if before != after:
lines.append(f" - `{key}`: `{before}` → `{after}`")
lines.append("")
return "\n".join(lines)
def render_unknown_and_missing(unknown: list[str], missing: list[str]) -> str:
if not (unknown or missing):
return ""
lines = ["## Metadata Gaps", ""]
if unknown:
lines.append(f"### Live settings not in metadata ({len(unknown)})")
lines.append("")
lines.append("These appeared in `fab api admin/tenantsettings` but have no curated entry. "
"Microsoft likely added them since the metadata was last updated.")
lines.append("")
for name in unknown:
lines.append(f"- `{name}`")
lines.append("")
if missing:
lines.append(f"### Metadata entries missing from live API ({len(missing)})")
lines.append("")
lines.append("These are in the metadata file but did not come back from the API. "
"Microsoft may have renamed or removed them.")
lines.append("")
for name in missing:
lines.append(f"- `{name}`")
lines.append("")
return "\n".join(lines)
def render_report(
records: list[SettingRecord],
unknown: list[str],
missing: list[str],
drift_only: bool,
diff: dict[str, list[Any]] | None,
previous_timestamp: str | None,
) -> str:
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
head = [
"# Fabric Tenant Settings Audit",
"",
f"Generated: {timestamp}",
"",
f"Baseline: `{METADATA_PATH.relative_to(SCRIPT_DIR.parent.parent)}`",
"",
]
sections = [
"\n".join(head),
render_summary(records, unknown, missing),
render_changes(diff, previous_timestamp),
render_drift_table(records),
render_individuals_table(records),
render_full_audit(records, drift_only),
render_unknown_and_missing(unknown, missing),
]
return "\n".join(s for s in sections if s)
# endregion
# region CLI
def main() -> int:
parser = argparse.ArgumentParser(description="Audit Fabric tenant settings against a recommendation baseline.")
parser.add_argument("-o", "--output", type=Path, help="Write markdown to this file instead of stdout.")
parser.add_argument("--drift-only", action="store_true",
help="Render only settings whose live state drifts from the recommended posture.")
parser.add_argument("--metadata", type=Path, default=METADATA_PATH,
help=f"Path to the curated metadata YAML (default: {METADATA_PATH}).")
parser.add_argument("--snapshot", type=Path, default=DEFAULT_SNAPSHOT,
help=f"Path to the previous-run snapshot JSON (default: {DEFAULT_SNAPSHOT}).")
parser.add_argument("--no-snapshot", action="store_true",
help="Skip loading and writing the snapshot; disables change detection.")
args = parser.parse_args()
metadata = load_metadata(args.metadata)
live = fetch_live_settings()
records, unknown, missing = merge(metadata, live)
diff: dict[str, list[Any]] | None = None
previous_timestamp: str | None = None
if not args.no_snapshot:
previous = load_snapshot(args.snapshot)
if previous is not None:
previous_timestamp = previous.get("timestamp")
diff = diff_snapshots(previous, live)
report = render_report(records, unknown, missing, args.drift_only, diff, previous_timestamp)
if args.output:
args.output.write_text(report)
print(f"wrote {len(report)} bytes to {args.output}", file=sys.stderr)
else:
sys.stdout.write(report)
if not args.no_snapshot:
save_snapshot(args.snapshot, live)
print(f"snapshot saved to {args.snapshot}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
# endregion
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "reportlab>=4.0",
# "pyyaml>=6.0",
# ]
# ///
"""Render a clean PDF summary of a Fabric tenant settings audit.
Re-uses the drift and change-detection logic from the sibling
audit-tenant-settings.py (imported via importlib so the hyphenated filename
still works), optionally enumerates delegated overrides, and builds a
compact, editorial-style A4 PDF focused on deviations from the baseline and
changes since the last snapshot.
Usage:
uv run ${CLAUDE_PLUGIN_ROOT}/skills/audit-tenant-settings/scripts/generate_audit_pdf.py \
-o /tmp/tenant-audit.pdf
Reads the same snapshot path as audit-tenant-settings.py so change detection
stays in lockstep. Pair it with the audit-tenant-settings skill workflow:
the PDF is intended as a shareable briefing, not a replacement for the full
markdown walk-through.
"""
# region Imports
from __future__ import annotations
import argparse
import importlib.util
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import cm
from reportlab.platypus import (
BaseDocTemplate,
Frame,
PageTemplate,
Paragraph,
Spacer,
Table,
TableStyle,
)
# endregion
# region Constants
SCRIPT_DIR = Path(__file__).resolve().parent
AUDIT_SCRIPT = SCRIPT_DIR / "audit-tenant-settings.py"
# Editorial / refined-minimal palette. Earth tones, no purples, no alarm reds.
INK = colors.HexColor("#0B0C0A")
WARM_GREY = colors.HexColor("#585A56")
HAIRLINE = colors.HexColor("#C2C0B6")
PAPER = colors.HexColor("#F7F5EE")
DRIFT = colors.HexColor("#8F5A0E") # muted amber
CRITICAL = colors.HexColor("#7A2E2C") # muted oxblood, high-risk only
COMPLIANT = colors.HexColor("#4E7247") # desaturated sage
# Hex strings for inline <font color="..."> markup in Paragraphs
INK_HEX = "#0B0C0A"
CRITICAL_HEX = "#7A2E2C"
COMPLIANT_HEX = "#4E7247"
# Light tint backgrounds for change-rows (regression / improvement)
LIGHT_RED_BG = colors.HexColor("#FBEEED")
LIGHT_GREEN_BG = colors.HexColor("#EDF3EB")
RISK_COLORS = {
"high": CRITICAL,
"medium": DRIFT,
"low": WARM_GREY,
}
MAX_DRIFT_ROWS = 40
MAX_TOGGLE_ROWS = 12
# endregion
# region Audit module import
def load_audit_module():
"""Load the hyphenated audit-tenant-settings.py as an importable module.
The audit script lives next to this file but its filename contains a
hyphen, so regular `import` can't reach it. spec_from_file_location +
exec_module loads it into sys.modules under a legal name.
"""
spec = importlib.util.spec_from_file_location("audit_tenant_settings", AUDIT_SCRIPT)
if spec is None or spec.loader is None:
sys.exit(f"could not create import spec for {AUDIT_SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules["audit_tenant_settings"] = module
spec.loader.exec_module(module)
return module
# endregion
# region Override enumeration
def fetch_overrides() -> dict[str, dict[str, Any]]:
"""Enumerate delegated overrides across capacity, domain, and workspace scopes.
Returns a dict keyed by scope. Each value is a dict with:
reachable: bool ; True if the endpoint returned HTTP 200.
items: list ; override entries (empty if none exist).
Distinguishing "reachable + empty" from "not reachable" lets the PDF
render an unambiguous overrides section rather than collapsing both
cases into one message.
"""
def _get(path: str) -> dict[str, Any]:
proc = subprocess.run(
["fab", "api", path, "--output_format", "json"],
capture_output=True,
text=True,
)
if proc.returncode != 0:
return {"reachable": False, "items": []}
try:
payload = json.loads(proc.stdout)
except json.JSONDecodeError:
return {"reachable": False, "items": []}
data_envelope = payload.get("result", {}).get("data", [{}])
if not data_envelope:
return {"reachable": False, "items": []}
entry = data_envelope[0]
status_code = entry.get("status_code")
text = entry.get("text", {}) or {}
# capacity ships `overrides` (legacy) + `value` (current); take whichever is populated
items = text.get("value") or text.get("overrides") or []
reachable = status_code == 200
return {"reachable": reachable, "items": items}
return {
"capacity": _get("admin/capacities/delegatedTenantSettingOverrides"),
"domain": _get("admin/domains/delegatedTenantSettingOverrides"),
"workspace": _get("admin/workspaces/delegatedTenantSettingOverrides"),
}
# endregion
# region Styles
def build_styles() -> dict[str, ParagraphStyle]:
"""Typographic system for the PDF.
Helvetica for prose, Helvetica-Bold for display, Courier for API names
and code. Generous leading, uppercase tracked section heads, strong
hierarchy between display/section/body.
"""
return {
"display": ParagraphStyle(
"display",
fontName="Helvetica-Bold",
fontSize=22,
leading=26,
textColor=INK,
spaceAfter=2,
tracking=0,
),
"display_sub": ParagraphStyle(
"display_sub",
fontName="Helvetica-Oblique",
fontSize=9,
leading=12,
textColor=WARM_GREY,
spaceAfter=14,
),
"section": ParagraphStyle(
"section",
fontName="Helvetica-Bold",
fontSize=10,
leading=12,
textColor=INK,
),
"body": ParagraphStyle(
"body",
fontName="Helvetica",
fontSize=9,
leading=12,
textColor=INK,
spaceAfter=6,
),
"mute": ParagraphStyle(
"mute",
fontName="Helvetica",
fontSize=8,
leading=11,
textColor=WARM_GREY,
spaceAfter=4,
),
"metric_number": ParagraphStyle(
"metric_number",
fontName="Helvetica-Bold",
fontSize=24,
leading=26,
textColor=INK,
alignment=1, # center
),
"metric_label": ParagraphStyle(
"metric_label",
fontName="Helvetica-Bold",
fontSize=7,
leading=9,
textColor=WARM_GREY,
alignment=1,
),
"cell": ParagraphStyle(
"cell",
fontName="Helvetica",
fontSize=8,
leading=11,
textColor=INK,
),
"cell_bold": ParagraphStyle(
"cell_bold",
fontName="Helvetica-Bold",
fontSize=8,
leading=11,
textColor=INK,
),
"cell_mute": ParagraphStyle(
"cell_mute",
fontName="Helvetica",
fontSize=8,
leading=11,
textColor=WARM_GREY,
),
"code": ParagraphStyle(
"code",
fontName="Courier",
fontSize=7,
leading=9,
textColor=WARM_GREY,
),
"sg_line": ParagraphStyle(
"sg_line",
fontName="Helvetica",
fontSize=7,
leading=9,
textColor=WARM_GREY,
),
}
def _tint(text: str, tint: str | None) -> str:
"""Wrap a string in an inline <font color> tag when a tint is set.
Lets a single Paragraph style drive hierarchy while callers override
color per-row; the reportlab Paragraph markup parser honours the tag.
"""
return f'<font color="{tint}">{text}</font>' if tint else text
def status_from_state(enabled: bool, enabled_sgs: list, excluded_sgs: list, recommended: str | None) -> str:
"""Compute a status for an arbitrary enabled/sg state against a recommendation.
Mirrors audit.status_of() but takes raw values so the changes table can
classify the PREVIOUS state from the snapshot (which is not a
SettingRecord) against the same recommendation vocabulary.
"""
if recommended is None:
return "unknown"
has_sg = bool(enabled_sgs) or bool(excluded_sgs)
if recommended == "on":
return "compliant" if (enabled and not has_sg) else "drift"
if recommended == "off":
return "compliant" if not enabled else "drift"
if recommended == "on:sg":
return "compliant" if (enabled and has_sg) else "drift"
return "unknown"
def _render_state_lines(enabled: bool, enabled_sgs: list, excluded_sgs: list, styles: dict[str, ParagraphStyle], tint: str | None) -> list:
"""Base state cell: `on`/`off` followed by zero or more group lines."""
base = "on" if enabled else "off"
cell: list = [Paragraph(_tint(base, tint), styles["cell"])]
for sg in enabled_sgs:
name = sg.get("name") or sg.get("graphId", "?")
cell.append(Paragraph(_tint(f"+ {name}", tint), styles["sg_line"]))
for sg in excluded_sgs:
name = sg.get("name") or sg.get("graphId", "?")
cell.append(Paragraph(_tint(f"− {name}", tint), styles["sg_line"]))
return cell
def render_current_cell(record, styles: dict[str, ParagraphStyle], tint: str | None = None) -> list:
"""Render the CURRENT state cell for a drift or changes row.
When a setting is enabled AND scoped to security groups, show `on` on
the first line and list each group (`+` for allowed, `-` for excluded)
on subsequent small grey lines. Plain `on` / `off` otherwise.
"""
return _render_state_lines(
record.live_enabled,
record.enabled_sg,
record.excluded_sg,
styles,
tint,
)
def render_previous_cell(prev_entry: dict, styles: dict[str, ParagraphStyle], tint: str | None = None) -> list:
"""Render the PREVIOUS state cell from a raw snapshot entry.
Used by the changes table to show what each setting looked like before
the most recent set of mutations.
"""
return _render_state_lines(
bool(prev_entry.get("enabled")),
prev_entry.get("enabledSecurityGroups", []) or [],
prev_entry.get("excludedSecurityGroups", []) or [],
styles,
tint,
)
def render_recommended_cell(recommended: str | None, styles: dict[str, ParagraphStyle], tint: str | None = None) -> list:
"""Render the RECOMMENDED state cell.
The recommendation vocabulary is `on` / `off` / `on:sg`. `on:sg` is
split into a base `on` line and a small grey human-readable hint so
the column never shows the raw on:sg token.
"""
if recommended is None:
return [Paragraph(_tint("-", tint), styles["cell"])]
if recommended == "on:sg":
return [
Paragraph(_tint("on", tint), styles["cell"]),
Paragraph(_tint("(for specific security groups)", tint), styles["sg_line"]),
]
return [Paragraph(_tint(recommended, tint), styles["cell"])]
def section_head(title: str, styles: dict[str, ParagraphStyle]) -> list:
"""Section heading: uppercase tracked text above a hairline rule."""
head = Table(
[[Paragraph(f"<b>{title.upper()}</b>", styles["section"])]],
colWidths=["100%"],
)
head.setStyle(TableStyle([
("LINEBELOW", (0, 0), (-1, -1), 0.4, INK),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 2),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
return [Spacer(1, 14), head, Spacer(1, 6)]
# endregion
# region Flowables: hero strip
def build_counts_strip(metrics: list[tuple[str, int]], styles: dict[str, ParagraphStyle], page_width: float) -> Table:
"""Horizontal metrics strip. Big numbers up top, tracked caps labels below.
Layout relies on Table VALIGN so the numbers bottom-align and the labels
top-align, meeting cleanly at a shared midline.
"""
rows = [
[Paragraph(f"{v:,}", styles["metric_number"]) for _, v in metrics],
[Paragraph(k.upper(), styles["metric_label"]) for k, _ in metrics],
]
col_width = page_width / len(metrics)
t = Table(
rows,
colWidths=[col_width] * len(metrics),
rowHeights=[30, 14],
)
t.setStyle(TableStyle([
("LINEABOVE", (0, 0), (-1, 0), 0.5, INK),
("LINEBELOW", (0, -1), (-1, -1), 0.5, INK),
("LINEBEFORE", (1, 0), (-1, -1), 0.25, HAIRLINE),
("VALIGN", (0, 0), (-1, 0), "BOTTOM"),
("VALIGN", (0, 1), (-1, 1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
return t
# endregion
# region Flowables: sections
def build_masthead(tenant_label: str, baseline_path: Path, styles: dict[str, ParagraphStyle]) -> list:
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
meta_parts = [f"Generated {ts}"]
meta_parts.append(
f"Baseline <font face='Courier' size='8'>{baseline_path.name}</font>"
)
if tenant_label:
meta_parts.append(f"Tenant {tenant_label}")
meta = " · ".join(meta_parts)
return [
Paragraph("FABRIC TENANT SETTINGS AUDIT", styles["display"]),
Paragraph(meta, styles["display_sub"]),
]
def build_changes_section(diff: dict[str, list[Any]] | None, previous_timestamp: str | None, previous: dict[str, Any] | None, records: list, audit, styles: dict[str, ParagraphStyle]) -> list:
els = section_head("Changes since last audit", styles)
if diff is None:
els.append(Paragraph(
"First run. No baseline snapshot to compare against.",
styles["mute"],
))
return els
added = diff.get("added", [])
removed = diff.get("removed", [])
toggled = diff.get("toggled", [])
sg_changed = diff.get("sg_changed", [])
prop_changed = diff.get("property_changed", [])
if not any([added, removed, toggled, sg_changed, prop_changed]):
when = previous_timestamp or "last run"
els.append(Paragraph(f"No changes since {when}.", styles["mute"]))
return els
since_label = previous_timestamp or "last run"
summary_bits = [
f"Since {since_label}",
f"added: <b>{len(added)}</b>",
f"removed: <b>{len(removed)}</b>",
f"toggled: <b>{len(toggled)}</b>",
f"group changes: <b>{len(sg_changed)}</b>",
f"property changes: <b>{len(prop_changed)}</b>",
]
els.append(Paragraph(" · ".join(summary_bits), styles["mute"]))
els.append(Spacer(1, 6))
previous_settings = (previous or {}).get("settings", {}) or {}
record_by_name = {r.api_name: r for r in records}
changed_names: set[str] = set()
for t in toggled:
changed_names.add(t["name"])
for g in sg_changed:
changed_names.add(g["name"])
for p in prop_changed:
changed_names.add(p["name"])
added_names = set(added)
removed_names = set(removed)
risk_order = {"high": 0, "medium": 1, "low": 2, None: 3}
def _sort_key(n: str) -> tuple:
r = record_by_name.get(n)
if r is None:
return (4, n)
return (risk_order.get(r.risk, 3), r.human_name)
ordered_active = sorted(changed_names | added_names, key=_sort_key)
ordered_removed = sorted(removed_names)
header = [
Paragraph("RISK", styles["cell_bold"]),
Paragraph("SETTING", styles["cell_bold"]),
Paragraph("PREVIOUS", styles["cell_bold"]),
Paragraph("CURRENT", styles["cell_bold"]),
Paragraph("RECOMMEND", styles["cell_bold"]),
]
rows = [header]
row_backgrounds: list[tuple[int, Any]] = []
for name in ordered_active:
rec = record_by_name.get(name)
if rec is None:
continue
is_new = name in added_names
curr_status = audit.status_of(rec)
if is_new:
prev_status = None
else:
prev_entry = previous_settings.get(name, {})
prev_status = status_from_state(
bool(prev_entry.get("enabled")),
prev_entry.get("enabledSecurityGroups", []) or [],
prev_entry.get("excludedSecurityGroups", []) or [],
rec.recommended,
)
tint: str | None = None
bg = None
if is_new:
if curr_status == "drift":
tint, bg = CRITICAL_HEX, LIGHT_RED_BG
elif curr_status == "compliant":
tint, bg = COMPLIANT_HEX, LIGHT_GREEN_BG
else:
if prev_status == "compliant" and curr_status == "drift":
tint, bg = CRITICAL_HEX, LIGHT_RED_BG
elif prev_status == "drift" and curr_status == "compliant":
tint, bg = COMPLIANT_HEX, LIGHT_GREEN_BG
if is_new:
prev_cell = [Paragraph(_tint("(new)", tint), styles["sg_line"])]
else:
prev_cell = render_previous_cell(previous_settings.get(name, {}), styles, tint)
risk_label = (rec.risk or "-").upper()
link_color = tint or INK_HEX
if rec.source_url:
name_html = f'<link href="{rec.source_url}" color="{link_color}"><u>{rec.human_name}</u></link>'
else:
name_html = _tint(rec.human_name, tint)
setting_cell = [
Paragraph(name_html, styles["cell"]),
Paragraph(_tint(rec.api_name, tint), styles["code"]),
]
rows.append([
Paragraph(_tint(risk_label, tint), styles["cell_bold"]),
setting_cell,
prev_cell,
render_current_cell(rec, styles, tint),
render_recommended_cell(rec.recommended, styles, tint),
])
if bg is not None:
row_backgrounds.append((len(rows) - 1, bg))
for name in ordered_removed:
prev_entry = previous_settings.get(name, {})
setting_cell = [
Paragraph(name, styles["cell"]),
Paragraph("(removed from API)", styles["sg_line"]),
]
rows.append([
Paragraph("-", styles["cell"]),
setting_cell,
render_previous_cell(prev_entry, styles),
[Paragraph("-", styles["cell"])],
[Paragraph("-", styles["cell"])],
])
if len(rows) == 1: # header only, no data rows
return els
tbl = Table(
rows,
colWidths=["10%", "42%", "18%", "15%", "15%"],
repeatRows=1,
)
style = [
("LINEBELOW", (0, 0), (-1, 0), 0.4, INK),
("LINEBELOW", (0, 1), (-1, -1), 0.2, HAIRLINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]
for ri, bg in row_backgrounds:
style.append(("BACKGROUND", (0, ri), (-1, ri), bg))
tbl.setStyle(TableStyle(style))
els.append(tbl)
return els
def build_drift_section(records: list, styles: dict[str, ParagraphStyle], audit) -> list:
els = section_head("Drift against recommended baseline", styles)
drift_records = [r for r in records if audit.status_of(r) == "drift"]
if not drift_records:
els.append(Paragraph(
"All settings with a hard recommendation match the baseline.",
styles["mute"],
))
return els
drift_records.sort(key=lambda r: (
{"high": 0, "medium": 1, "low": 2, None: 3}.get(r.risk, 3),
r.group,
r.human_name,
))
header = [
Paragraph("RISK", styles["cell_bold"]),
Paragraph("SETTING", styles["cell_bold"]),
Paragraph("GROUP", styles["cell_bold"]),
Paragraph("CURRENT", styles["cell_bold"]),
Paragraph("RECOMMEND", styles["cell_bold"]),
]
rows = [header]
shown = drift_records[:MAX_DRIFT_ROWS]
for r in shown:
risk_label = (r.risk or "-").upper()
# Human name links to the official doc when a source_url is present;
# ReportLab <link> tags are clickable in every conforming PDF reader.
if r.source_url:
name_html = f'<link href="{r.source_url}" color="#0B0C0A"><u>{r.human_name}</u></link>'
else:
name_html = r.human_name
setting_cell = [
Paragraph(name_html, styles["cell"]),
Paragraph(r.api_name, styles["code"]),
]
rows.append([
Paragraph(risk_label, styles["cell_bold"]),
setting_cell,
Paragraph(r.group, styles["cell_mute"]),
render_current_cell(r, styles),
render_recommended_cell(r.recommended, styles),
])
tbl = Table(
rows,
colWidths=["10%", "42%", "18%", "15%", "15%"],
repeatRows=1,
)
style = [
("LINEBELOW", (0, 0), (-1, 0), 0.4, INK),
("LINEBELOW", (0, 1), (-1, -1), 0.2, HAIRLINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
]
for i, r in enumerate(shown, start=1):
style.append((
"TEXTCOLOR", (0, i), (0, i),
RISK_COLORS.get(r.risk, WARM_GREY),
))
tbl.setStyle(TableStyle(style))
els.append(tbl)
if len(drift_records) > MAX_DRIFT_ROWS:
els.append(Spacer(1, 4))
els.append(Paragraph(
f"{len(drift_records) - MAX_DRIFT_ROWS} additional drift rows omitted; "
f"see the full markdown audit for complete coverage.",
styles["mute"],
))
return els
def build_overrides_section(overrides: dict[str, dict[str, Any]], skipped: bool, styles: dict[str, ParagraphStyle]) -> list:
els = section_head("Delegated overrides", styles)
if skipped:
els.append(Paragraph(
"Override enumeration skipped via --no-overrides.",
styles["mute"],
))
return els
unreachable = [scope for scope, r in overrides.items() if not r["reachable"]]
total = sum(len(r["items"]) for r in overrides.values())
if unreachable and total == 0:
els.append(Paragraph(
"Override endpoints not reachable for scope(s): "
f"<b>{', '.join(unreachable)}</b>. "
"Verify Fabric admin role and re-run; this section can only populate when all three admin endpoints succeed.",
styles["mute"],
))
return els
if total == 0:
els.append(Paragraph(
"No delegated overrides found at capacity, domain, or workspace scope. "
"Tenant-wide defaults are the effective posture for every setting.",
styles["mute"],
))
return els
rows = [[
Paragraph("SCOPE", styles["cell_bold"]),
Paragraph("COUNT", styles["cell_bold"]),
]]
for scope in ("capacity", "domain", "workspace"):
count_text = str(len(overrides[scope]["items"]))
if not overrides[scope]["reachable"]:
count_text = "unreachable"
rows.append([
Paragraph(scope.capitalize(), styles["cell"]),
Paragraph(count_text, styles["cell"]),
])
rows.append([
Paragraph("<b>Total</b>", styles["cell_bold"]),
Paragraph(f"<b>{total}</b>", styles["cell_bold"]),
])
tbl = Table(rows, colWidths=["72%", "28%"])
tbl.setStyle(TableStyle([
("LINEBELOW", (0, 0), (-1, 0), 0.4, INK),
("LINEBELOW", (0, 1), (-1, -2), 0.2, HAIRLINE),
("LINEABOVE", (0, -1), (-1, -1), 0.4, INK),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
]))
els.append(tbl)
els.append(Spacer(1, 6))
els.append(Paragraph(
"Review each override individually in the portal or via fabric-cli and "
"tag as drift-vs-tenant, drift-vs-recommended, high-risk, or orphan. "
"See references/delegated-overrides.md for the full classification workflow.",
styles["mute"],
))
return els
# endregion
# region Footer
def draw_footer(canvas, doc):
"""Thin hairline rule, disclaimer text, and a page number in small caps."""
canvas.saveState()
canvas.setStrokeColor(HAIRLINE)
canvas.setLineWidth(0.3)
w, _ = A4
canvas.line(2 * cm, 1.6 * cm, w - 2 * cm, 1.6 * cm)
canvas.setFont("Helvetica", 7)
canvas.setFillColor(WARM_GREY)
canvas.drawString(
2 * cm, 1.15 * cm,
"Read-only audit. Recommendations are general, not prescriptive. "
"User is responsible for due diligence before applying any change.",
)
canvas.drawRightString(w - 2 * cm, 1.15 * cm, f"PAGE {doc.page}")
canvas.restoreState()
# endregion
# region Main
def main() -> int:
parser = argparse.ArgumentParser(
description="Render a clean PDF summary of a Fabric tenant settings audit.",
)
parser.add_argument("-o", "--output", type=Path, required=True,
help="Path to write the PDF to.")
parser.add_argument("--snapshot", type=Path, default=None,
help="Path to the previous-run snapshot JSON; defaults to the path audit-tenant-settings.py uses.")
parser.add_argument("--no-snapshot", action="store_true",
help="Skip snapshot load/save; disables change detection.")
parser.add_argument("--no-overrides", action="store_true",
help="Skip delegated-override enumeration (faster; use when not an admin).")
parser.add_argument("--tenant-label", type=str, default="",
help="Optional tenant display name shown in the masthead.")
args = parser.parse_args()
audit = load_audit_module()
snapshot_path = args.snapshot or audit.DEFAULT_SNAPSHOT
metadata = audit.load_metadata(audit.METADATA_PATH)
live = audit.fetch_live_settings()
records, unknown, missing = audit.merge(metadata, live)
diff: dict[str, list[Any]] | None = None
previous: dict[str, Any] | None = None
previous_timestamp: str | None = None
if not args.no_snapshot:
previous = audit.load_snapshot(snapshot_path)
if previous is not None:
previous_timestamp = previous.get("timestamp")
diff = audit.diff_snapshots(previous, live)
overrides: dict[str, dict[str, Any]] = {
scope: {"reachable": False, "items": []}
for scope in ("capacity", "domain", "workspace")
}
if not args.no_overrides:
overrides = fetch_overrides()
total = len(records)
compliant = sum(1 for r in records if audit.status_of(r) == "compliant")
drift_count = sum(1 for r in records if audit.status_of(r) == "drift")
preview = sum(1 for r in records if r.preview)
sg_scoped = sum(1 for r in records if (r.enabled_sg or r.excluded_sg))
high_risk_drift = sum(
1 for r in records
if audit.status_of(r) == "drift" and r.risk == "high"
)
styles = build_styles()
args.output.parent.mkdir(parents=True, exist_ok=True)
doc = BaseDocTemplate(
str(args.output),
pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm,
topMargin=2 * cm, bottomMargin=2.4 * cm,
title="Fabric Tenant Settings Audit",
author="audit-tenant-settings",
)
frame = Frame(
doc.leftMargin, doc.bottomMargin,
doc.width, doc.height,
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0,
)
doc.addPageTemplates([PageTemplate(id="body", frames=frame, onPage=draw_footer)])
story: list = []
story += build_masthead(args.tenant_label, audit.METADATA_PATH, styles)
story.append(build_counts_strip(
[
("Total", total),
("Compliant", compliant),
("Drift", drift_count),
("High-risk drift", high_risk_drift),
("Preview", preview),
("With groups", sg_scoped),
],
styles,
doc.width,
))
story += build_changes_section(diff, previous_timestamp, previous, records, audit, styles)
story += build_drift_section(records, styles, audit)
story += build_overrides_section(overrides, args.no_overrides, styles)
doc.build(story)
if not args.no_snapshot:
audit.save_snapshot(snapshot_path, live)
print(f"snapshot saved to {snapshot_path}", file=sys.stderr)
print(f"wrote {args.output}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
# endregion