d3e0769799
- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root; Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/ - Add AGENTS.md with project architecture, entry points, and security notes - Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts - Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport, Export-ObjectInventoryReport) and CA wizard helpers - Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath, and optimized group loading - Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry - Update Extensions for Settings Catalog definition auto-export - Update README with v4.1.0, new entry points and script catalog - Bump VERSION to 4.1.0 - Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports, Settings.json and IntuneManagement.log
174 lines
5.8 KiB
Python
174 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate a policy assignment inventory CSV from Intune backup JSON files.
|
|
|
|
Walks every JSON file under the backup root and emits one row per assignment
|
|
target (or one row per unassigned/not-exported object).
|
|
|
|
Output columns: PolicyType, ObjectName, ObjectType, AssignmentState,
|
|
Intent, AssignmentTarget, TargetType, AssignmentFilter,
|
|
FilterType, SourceFile
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Iterator
|
|
|
|
_GROUP_TARGET_TYPES = {
|
|
"#microsoft.graph.groupAssignmentTarget",
|
|
"#microsoft.graph.exclusionGroupAssignmentTarget",
|
|
}
|
|
|
|
_EXCLUDED_DIRS = {"reports", "__archive__"}
|
|
|
|
FIELDNAMES = [
|
|
"PolicyType",
|
|
"ObjectName",
|
|
"ObjectType",
|
|
"AssignmentState",
|
|
"Intent",
|
|
"AssignmentTarget",
|
|
"TargetType",
|
|
"AssignmentFilter",
|
|
"FilterType",
|
|
"SourceFile",
|
|
]
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("--root", required=True,
|
|
help="Path to backup root (e.g. tenant-state/intune).")
|
|
p.add_argument("--output", default="assignment-report.csv",
|
|
help="Output CSV path (default: assignment-report.csv).")
|
|
p.add_argument("--policy-type", action="append", default=[],
|
|
help="Filter to specific top-level folder names (repeat or comma-separate).")
|
|
return p.parse_args()
|
|
|
|
|
|
def _safe(value: object) -> str:
|
|
return "" if value is None else str(value).strip()
|
|
|
|
|
|
def _resolve_target(target: dict) -> tuple[str, str]:
|
|
"""Returns (display_name, target_type_short)."""
|
|
ttype = _safe(target.get("@odata.type"))
|
|
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
|
|
return "All devices", ttype
|
|
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
|
|
return "All users", ttype
|
|
if ttype in _GROUP_TARGET_TYPES:
|
|
name = (target.get("groupDisplayName") or target.get("groupName")
|
|
or target.get("groupId") or "Unresolved group")
|
|
return _safe(name), ttype
|
|
return (_safe(target.get("groupDisplayName") or target.get("displayName")
|
|
or target.get("id")) or "Unknown target", ttype)
|
|
|
|
|
|
def _infer_intent(assignment: dict, target_type: str) -> str:
|
|
if "exclusion" in target_type.lower():
|
|
return "Exclude"
|
|
explicit = _safe(assignment.get("intent")).lower()
|
|
if explicit in {"exclude"}:
|
|
return "Exclude"
|
|
return "Include"
|
|
|
|
|
|
def _iter_rows(root: Path, policy_type_filter: set[str]) -> Iterator[dict]:
|
|
for path in sorted(root.rglob("*.json")):
|
|
try:
|
|
rel = path.relative_to(root)
|
|
except ValueError:
|
|
continue
|
|
if any(part in _EXCLUDED_DIRS for part in rel.parts):
|
|
continue
|
|
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
|
|
policy_type = rel.parts[0] if rel.parts else ""
|
|
if policy_type_filter and policy_type.lower() not in policy_type_filter:
|
|
continue
|
|
|
|
object_name = (_safe(payload.get("displayName")) or _safe(payload.get("name"))
|
|
or path.stem.split("__")[0])
|
|
object_type = _safe(payload.get("@odata.type"))
|
|
source = rel.as_posix()
|
|
|
|
base = {
|
|
"PolicyType": policy_type,
|
|
"ObjectName": object_name,
|
|
"ObjectType": object_type,
|
|
"SourceFile": source,
|
|
}
|
|
|
|
assignments = payload.get("assignments")
|
|
if not isinstance(assignments, list):
|
|
yield {**base, "AssignmentState": "NotExported", "Intent": "",
|
|
"AssignmentTarget": "Not exported in backup", "TargetType": "",
|
|
"AssignmentFilter": "", "FilterType": ""}
|
|
continue
|
|
|
|
valid = [a for a in assignments if isinstance(a, dict)]
|
|
if not valid:
|
|
yield {**base, "AssignmentState": "Unassigned", "Intent": "",
|
|
"AssignmentTarget": "No assignments", "TargetType": "",
|
|
"AssignmentFilter": "", "FilterType": ""}
|
|
continue
|
|
|
|
for assignment in valid:
|
|
target = assignment.get("target") or {}
|
|
target_name, target_type = _resolve_target(target)
|
|
intent = _infer_intent(assignment, target_type)
|
|
yield {
|
|
**base,
|
|
"AssignmentState": "Assigned",
|
|
"Intent": intent,
|
|
"AssignmentTarget": target_name,
|
|
"TargetType": target_type,
|
|
"AssignmentFilter": _safe(target.get("deviceAndAppManagementAssignmentFilterId")),
|
|
"FilterType": _safe(target.get("deviceAndAppManagementAssignmentFilterType")),
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
root = Path(args.root).resolve()
|
|
out_path = Path(args.output)
|
|
|
|
if not root.exists():
|
|
raise SystemExit(f"Backup root not found: {root}")
|
|
|
|
policy_type_filter: set[str] = set()
|
|
for raw in args.policy_type:
|
|
for part in raw.split(","):
|
|
v = part.strip().lower()
|
|
if v:
|
|
policy_type_filter.add(v)
|
|
|
|
rows = sorted(
|
|
_iter_rows(root, policy_type_filter),
|
|
key=lambda r: (r["PolicyType"].lower(), r["ObjectName"].lower(),
|
|
r["AssignmentState"], r["Intent"].lower(),
|
|
r["AssignmentTarget"].lower()),
|
|
)
|
|
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with out_path.open("w", newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=FIELDNAMES, extrasaction="ignore")
|
|
writer.writeheader()
|
|
writer.writerows(rows)
|
|
|
|
print(f"Written {len(rows)} rows → {out_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|