#!/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()