Created
February 25, 2026 13:29
-
-
Save RajChowdhury240/2c5326d8f6295f608755bfebff6b9ee4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| AWS Identity Center (SSO) Comprehensive Report Generator | |
| Enumerates users, groups, memberships, permission sets, and account assignments. | |
| Outputs a modern, interactive HTML dashboard. | |
| """ | |
| import boto3 | |
| import json | |
| import html | |
| import sys | |
| from datetime import datetime, timezone | |
| from collections import defaultdict | |
| # ─── AWS API Helpers ──────────────────────────────────────────────────────── | |
| def paginate(client, method, result_key, **kwargs): | |
| """Generic paginator for AWS APIs.""" | |
| items = [] | |
| paginator = client.get_paginator(method) | |
| for page in paginator.paginate(**kwargs): | |
| items.extend(page.get(result_key, [])) | |
| return items | |
| def get_sso_instance(sso_admin): | |
| """Discover the SSO instance ARN and Identity Store ID.""" | |
| resp = sso_admin.list_instances() | |
| instances = resp.get("Instances", []) | |
| if not instances: | |
| print("ERROR: No AWS Identity Center instance found in this account/region.") | |
| sys.exit(1) | |
| inst = instances[0] | |
| return inst["InstanceArn"], inst["IdentityStoreId"] | |
| # ─── Data Collection ──────────────────────────────────────────────────────── | |
| def collect_users(identity_store, store_id): | |
| """List all users in the identity store.""" | |
| print(" [*] Listing users...") | |
| users = paginate(identity_store, "list_users", "Users", IdentityStoreId=store_id) | |
| print(f" Found {len(users)} users") | |
| return users | |
| def collect_groups(identity_store, store_id): | |
| """List all groups in the identity store.""" | |
| print(" [*] Listing groups...") | |
| groups = paginate(identity_store, "list_groups", "Groups", IdentityStoreId=store_id) | |
| print(f" Found {len(groups)} groups") | |
| return groups | |
| def collect_group_memberships(identity_store, store_id, groups, user_map): | |
| """For each group, list its members.""" | |
| print(" [*] Collecting group memberships...") | |
| memberships = {} | |
| for g in groups: | |
| gid = g["GroupId"] | |
| members = paginate( | |
| identity_store, "list_group_memberships", "GroupMemberships", | |
| IdentityStoreId=store_id, GroupId=gid | |
| ) | |
| member_list = [] | |
| for m in members: | |
| member_id_obj = m.get("MemberId", {}) | |
| uid = member_id_obj.get("UserId") | |
| if uid and uid in user_map: | |
| member_list.append(uid) | |
| memberships[gid] = member_list | |
| total = sum(len(v) for v in memberships.values()) | |
| print(f" Found {total} total memberships across {len(memberships)} groups") | |
| return memberships | |
| def collect_permission_sets(sso_admin, instance_arn): | |
| """List all permission sets and describe each one.""" | |
| print(" [*] Listing permission sets...") | |
| ps_arns = paginate( | |
| sso_admin, "list_permission_sets", "PermissionSets", | |
| InstanceArn=instance_arn | |
| ) | |
| permission_sets = {} | |
| for arn in ps_arns: | |
| desc = sso_admin.describe_permission_set( | |
| InstanceArn=instance_arn, PermissionSetArn=arn | |
| ).get("PermissionSet", {}) | |
| # Get inline policy | |
| try: | |
| inline = sso_admin.get_inline_policy_for_permission_set( | |
| InstanceArn=instance_arn, PermissionSetArn=arn | |
| ).get("InlinePolicy", "") | |
| except Exception: | |
| inline = "" | |
| # Get managed policies | |
| try: | |
| managed = paginate( | |
| sso_admin, "list_managed_policies_in_permission_set", | |
| "AttachedManagedPolicies", | |
| InstanceArn=instance_arn, PermissionSetArn=arn | |
| ) | |
| except Exception: | |
| managed = [] | |
| # Get customer managed policies | |
| try: | |
| customer_managed = paginate( | |
| sso_admin, "list_customer_managed_policy_references_in_permission_set", | |
| "CustomerManagedPolicyReferences", | |
| InstanceArn=instance_arn, PermissionSetArn=arn | |
| ) | |
| except Exception: | |
| customer_managed = [] | |
| # Get permission boundary | |
| try: | |
| boundary = sso_admin.get_permissions_boundary_for_permission_set( | |
| InstanceArn=instance_arn, PermissionSetArn=arn | |
| ).get("PermissionsBoundary", {}) | |
| except Exception: | |
| boundary = {} | |
| permission_sets[arn] = { | |
| "details": desc, | |
| "inline_policy": inline, | |
| "managed_policies": managed, | |
| "customer_managed_policies": customer_managed, | |
| "permissions_boundary": boundary, | |
| } | |
| print(f" Found {len(permission_sets)} permission sets") | |
| return permission_sets | |
| def collect_account_assignments(sso_admin, instance_arn, permission_sets, account_map): | |
| """For each account + permission set, list all assignments.""" | |
| print(" [*] Collecting account assignments (this may take a while)...") | |
| assignments = [] | |
| account_ids = list(account_map.keys()) | |
| ps_arns = list(permission_sets.keys()) | |
| total_combos = len(account_ids) * len(ps_arns) | |
| done = 0 | |
| for acct_id in account_ids: | |
| for ps_arn in ps_arns: | |
| done += 1 | |
| if done % 20 == 0 or done == total_combos: | |
| print(f" Progress: {done}/{total_combos} combinations checked...") | |
| try: | |
| acct_assignments = paginate( | |
| sso_admin, | |
| "list_account_assignments", | |
| "AccountAssignments", | |
| InstanceArn=instance_arn, | |
| AccountId=acct_id, | |
| PermissionSetArn=ps_arn | |
| ) | |
| for a in acct_assignments: | |
| a["PermissionSetArn"] = ps_arn | |
| assignments.append(a) | |
| except Exception as e: | |
| print(f" Warning: Could not list assignments for {acct_id}/{ps_arn}: {e}") | |
| print(f" Found {len(assignments)} total assignments") | |
| return assignments | |
| def collect_accounts(org_client): | |
| """List all AWS accounts in the organization.""" | |
| print(" [*] Listing AWS accounts...") | |
| try: | |
| accounts = paginate(org_client, "list_accounts", "Accounts") | |
| print(f" Found {len(accounts)} accounts") | |
| return {a["Id"]: a for a in accounts} | |
| except Exception as e: | |
| print(f" Warning: Could not list org accounts ({e}). Account names may be unavailable.") | |
| return {} | |
| # ─── HTML Report ──────────────────────────────────────────────────────────── | |
| def esc(text): | |
| """HTML-escape a string.""" | |
| if text is None: | |
| return "" | |
| return html.escape(str(text)) | |
| def generate_html(users, groups, memberships, permission_sets, assignments, account_map, user_map, group_map): | |
| """Generate the full interactive HTML report.""" | |
| now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") | |
| # Pre-process data for the report | |
| # Build user -> groups mapping | |
| user_groups = defaultdict(list) | |
| for gid, member_uids in memberships.items(): | |
| for uid in member_uids: | |
| user_groups[uid].append(gid) | |
| # Build structured assignments | |
| # assignment_by_account[account_id] = [ {principal, principal_type, permission_set_name, ps_arn} ] | |
| assignment_by_account = defaultdict(list) | |
| assignment_by_principal = defaultdict(list) | |
| for a in assignments: | |
| acct = a.get("AccountId", "") | |
| pid = a.get("PrincipalId", "") | |
| ptype = a.get("PrincipalType", "") | |
| ps_arn = a.get("PermissionSetArn", "") | |
| ps_name = permission_sets.get(ps_arn, {}).get("details", {}).get("Name", ps_arn.split("/")[-1]) | |
| if ptype == "USER": | |
| principal_name = user_map.get(pid, {}).get("UserName", pid) | |
| elif ptype == "GROUP": | |
| principal_name = group_map.get(pid, {}).get("DisplayName", pid) | |
| else: | |
| principal_name = pid | |
| entry = { | |
| "account_id": acct, | |
| "account_name": account_map.get(acct, {}).get("Name", acct), | |
| "principal_id": pid, | |
| "principal_name": principal_name, | |
| "principal_type": ptype, | |
| "permission_set_name": ps_name, | |
| "permission_set_arn": ps_arn, | |
| } | |
| assignment_by_account[acct].append(entry) | |
| assignment_by_principal[pid].append(entry) | |
| # Stats | |
| total_users = len(users) | |
| total_groups = len(groups) | |
| total_ps = len(permission_sets) | |
| total_accounts = len(account_map) | |
| total_assignments = len(assignments) | |
| # ── Build HTML ── | |
| parts = [] | |
| parts.append(f"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AWS Identity Center Report</title> | |
| <style> | |
| :root {{ | |
| --bg: #0f1117; | |
| --surface: #161b22; | |
| --surface2: #1c2129; | |
| --border: #30363d; | |
| --text: #e6edf3; | |
| --text-muted: #8b949e; | |
| --accent: #58a6ff; | |
| --accent2: #3fb950; | |
| --accent3: #d2a8ff; | |
| --accent4: #f0883e; | |
| --accent5: #f778ba; | |
| --danger: #f85149; | |
| --badge-user: #1f3a5f; | |
| --badge-group: #1a3828; | |
| --badge-ps: #2d1f4e; | |
| --badge-acct: #3d2b10; | |
| }} | |
| * {{ margin:0; padding:0; box-sizing:border-box; }} | |
| body {{ | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| line-height: 1.5; | |
| }} | |
| .container {{ max-width: 1400px; margin: 0 auto; padding: 20px; }} | |
| /* Header */ | |
| .header {{ | |
| background: linear-gradient(135deg, #0d1117 0%, #161b22 50%, #1a1e2e 100%); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 32px; | |
| margin-bottom: 24px; | |
| }} | |
| .header h1 {{ | |
| font-size: 28px; | |
| font-weight: 700; | |
| background: linear-gradient(90deg, var(--accent), var(--accent3)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 8px; | |
| }} | |
| .header .meta {{ color: var(--text-muted); font-size: 14px; }} | |
| /* Stats bar */ | |
| .stats {{ | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 16px; | |
| margin-bottom: 24px; | |
| }} | |
| .stat-card {{ | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 20px; | |
| text-align: center; | |
| transition: transform 0.15s, border-color 0.15s; | |
| }} | |
| .stat-card:hover {{ transform: translateY(-2px); border-color: var(--accent); }} | |
| .stat-card .number {{ font-size: 36px; font-weight: 700; }} | |
| .stat-card .label {{ font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-top: 4px; }} | |
| .stat-card:nth-child(1) .number {{ color: var(--accent); }} | |
| .stat-card:nth-child(2) .number {{ color: var(--accent2); }} | |
| .stat-card:nth-child(3) .number {{ color: var(--accent3); }} | |
| .stat-card:nth-child(4) .number {{ color: var(--accent4); }} | |
| .stat-card:nth-child(5) .number {{ color: var(--accent5); }} | |
| /* Tabs */ | |
| .tabs {{ | |
| display: flex; | |
| gap: 4px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 6px; | |
| margin-bottom: 24px; | |
| overflow-x: auto; | |
| }} | |
| .tab {{ | |
| padding: 10px 20px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-muted); | |
| font-size: 14px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| border-radius: 8px; | |
| transition: all 0.15s; | |
| white-space: nowrap; | |
| }} | |
| .tab:hover {{ color: var(--text); background: var(--surface2); }} | |
| .tab.active {{ color: var(--text); background: var(--accent); color: #fff; }} | |
| .tab-content {{ display: none; }} | |
| .tab-content.active {{ display: block; }} | |
| /* Search */ | |
| .search-bar {{ | |
| width: 100%; | |
| padding: 12px 16px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| color: var(--text); | |
| font-size: 14px; | |
| margin-bottom: 16px; | |
| outline: none; | |
| transition: border-color 0.15s; | |
| }} | |
| .search-bar:focus {{ border-color: var(--accent); }} | |
| .search-bar::placeholder {{ color: var(--text-muted); }} | |
| /* Tables */ | |
| .table-wrap {{ | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| overflow: hidden; | |
| }} | |
| table {{ | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 14px; | |
| }} | |
| thead th {{ | |
| background: var(--surface2); | |
| padding: 12px 16px; | |
| text-align: left; | |
| font-weight: 600; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| font-size: 12px; | |
| letter-spacing: 0.5px; | |
| border-bottom: 1px solid var(--border); | |
| cursor: pointer; | |
| user-select: none; | |
| white-space: nowrap; | |
| position: sticky; | |
| top: 0; | |
| z-index: 1; | |
| }} | |
| thead th:hover {{ color: var(--accent); }} | |
| thead th .sort-icon {{ margin-left: 4px; opacity: 0.4; }} | |
| thead th.sorted .sort-icon {{ opacity: 1; color: var(--accent); }} | |
| tbody td {{ | |
| padding: 10px 16px; | |
| border-bottom: 1px solid var(--border); | |
| vertical-align: top; | |
| }} | |
| tbody tr {{ transition: background 0.1s; }} | |
| tbody tr:hover {{ background: var(--surface2); }} | |
| tbody tr:last-child td {{ border-bottom: none; }} | |
| /* Badges */ | |
| .badge {{ | |
| display: inline-block; | |
| padding: 2px 10px; | |
| border-radius: 20px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| margin: 2px 4px 2px 0; | |
| white-space: nowrap; | |
| }} | |
| .badge-user {{ background: var(--badge-user); color: var(--accent); }} | |
| .badge-group {{ background: var(--badge-group); color: var(--accent2); }} | |
| .badge-ps {{ background: var(--badge-ps); color: var(--accent3); }} | |
| .badge-acct {{ background: var(--badge-acct); color: var(--accent4); }} | |
| .badge-type {{ background: #2d333b; color: var(--text-muted); }} | |
| /* Expandable rows */ | |
| .expand-btn {{ | |
| background: none; | |
| border: 1px solid var(--border); | |
| color: var(--accent); | |
| padding: 4px 10px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| transition: all 0.15s; | |
| }} | |
| .expand-btn:hover {{ background: var(--accent); color: #fff; }} | |
| .detail-row {{ display: none; }} | |
| .detail-row.show {{ display: table-row; }} | |
| .detail-row td {{ | |
| background: var(--surface2); | |
| padding: 16px 24px; | |
| }} | |
| .detail-content {{ | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 16px; | |
| }} | |
| .detail-section h4 {{ | |
| font-size: 13px; | |
| color: var(--accent); | |
| margin-bottom: 8px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| }} | |
| .detail-section pre {{ | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 12px; | |
| font-size: 12px; | |
| overflow-x: auto; | |
| max-height: 300px; | |
| overflow-y: auto; | |
| color: var(--text-muted); | |
| }} | |
| .detail-section ul {{ list-style: none; }} | |
| .detail-section ul li {{ | |
| padding: 4px 0; | |
| font-size: 13px; | |
| color: var(--text-muted); | |
| }} | |
| .detail-section ul li::before {{ | |
| content: "→ "; | |
| color: var(--accent); | |
| }} | |
| /* No results */ | |
| .no-results {{ | |
| text-align: center; | |
| padding: 40px; | |
| color: var(--text-muted); | |
| font-size: 14px; | |
| }} | |
| /* Scrollable table container */ | |
| .table-scroll {{ | |
| max-height: 70vh; | |
| overflow-y: auto; | |
| }} | |
| .table-scroll::-webkit-scrollbar {{ width: 8px; }} | |
| .table-scroll::-webkit-scrollbar-track {{ background: var(--surface); }} | |
| .table-scroll::-webkit-scrollbar-thumb {{ background: var(--border); border-radius: 4px; }} | |
| .table-scroll::-webkit-scrollbar-thumb:hover {{ background: var(--text-muted); }} | |
| /* Count badge */ | |
| .count {{ color: var(--text-muted); font-size: 12px; margin-left: 4px; }} | |
| /* Tooltip */ | |
| .tip {{ | |
| position: relative; | |
| cursor: help; | |
| border-bottom: 1px dotted var(--text-muted); | |
| }} | |
| .tip:hover::after {{ | |
| content: attr(data-tip); | |
| position: absolute; | |
| bottom: 100%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: #000; | |
| color: #fff; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| white-space: nowrap; | |
| z-index: 10; | |
| }} | |
| @media (max-width: 768px) {{ | |
| .container {{ padding: 12px; }} | |
| .header {{ padding: 20px; }} | |
| .header h1 {{ font-size: 22px; }} | |
| .stats {{ grid-template-columns: repeat(2, 1fr); }} | |
| .tabs {{ flex-wrap: wrap; }} | |
| table {{ font-size: 13px; }} | |
| thead th, tbody td {{ padding: 8px 10px; }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <h1>AWS Identity Center Report</h1> | |
| <div class="meta">Generated: {now}</div> | |
| </div> | |
| <div class="stats"> | |
| <div class="stat-card"><div class="number">{total_users}</div><div class="label">Users</div></div> | |
| <div class="stat-card"><div class="number">{total_groups}</div><div class="label">Groups</div></div> | |
| <div class="stat-card"><div class="number">{total_ps}</div><div class="label">Permission Sets</div></div> | |
| <div class="stat-card"><div class="number">{total_accounts}</div><div class="label">Accounts</div></div> | |
| <div class="stat-card"><div class="number">{total_assignments}</div><div class="label">Assignments</div></div> | |
| </div> | |
| <div class="tabs"> | |
| <button class="tab active" data-tab="tab-users">Users</button> | |
| <button class="tab" data-tab="tab-groups">Groups</button> | |
| <button class="tab" data-tab="tab-permsets">Permission Sets</button> | |
| <button class="tab" data-tab="tab-accounts">Accounts</button> | |
| <button class="tab" data-tab="tab-assignments">All Assignments</button> | |
| <button class="tab" data-tab="tab-matrix">Access Matrix</button> | |
| </div> | |
| """) | |
| # ── TAB: Users ── | |
| parts.append(""" | |
| <div id="tab-users" class="tab-content active"> | |
| <input class="search-bar" type="text" placeholder="Search users by name, email, ID..." onkeyup="filterTable(this, 'users-table')"> | |
| <div class="table-wrap"><div class="table-scroll"> | |
| <table id="users-table"> | |
| <thead><tr> | |
| <th onclick="sortTable('users-table',0)">Username <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('users-table',1)">Display Name <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('users-table',2)">Email <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('users-table',3)">Groups <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('users-table',4)">Account Access <span class="sort-icon">⇅</span></th> | |
| <th>Details</th> | |
| </tr></thead> | |
| <tbody> | |
| """) | |
| for u in sorted(users, key=lambda x: x.get("UserName", "")): | |
| uid = u["UserId"] | |
| uname = esc(u.get("UserName", "")) | |
| display = esc(u.get("DisplayName", "")) | |
| emails = u.get("Emails", []) | |
| email = esc(emails[0].get("Value", "")) if emails else "" | |
| grps = user_groups.get(uid, []) | |
| grp_badges = "".join( | |
| f'<span class="badge badge-group">{esc(group_map.get(gid, {}).get("DisplayName", gid))}</span>' | |
| for gid in grps | |
| ) or '<span style="color:var(--text-muted)">None</span>' | |
| acct_access = assignment_by_principal.get(uid, []) | |
| # Also include access via groups | |
| for gid in grps: | |
| acct_access.extend(assignment_by_principal.get(gid, [])) | |
| unique_accts = list({a["account_id"] for a in acct_access}) | |
| acct_badges = "".join( | |
| f'<span class="badge badge-acct">{esc(account_map.get(aid, {}).get("Name", aid))}</span>' | |
| for aid in sorted(unique_accts) | |
| ) or '<span style="color:var(--text-muted)">None</span>' | |
| row_id = f"user-{uid}" | |
| parts.append(f"""<tr> | |
| <td><strong>{uname}</strong></td> | |
| <td>{display}</td> | |
| <td>{email}</td> | |
| <td>{grp_badges}</td> | |
| <td>{acct_badges}</td> | |
| <td><button class="expand-btn" onclick="toggleDetail('{row_id}')">Details</button></td> | |
| </tr> | |
| <tr class="detail-row" id="{row_id}"><td colspan="6"><div class="detail-content"> | |
| <div class="detail-section"><h4>User Info</h4><ul> | |
| <li>User ID: {esc(uid)}</li> | |
| <li>Username: {uname}</li> | |
| <li>Display Name: {display}</li> | |
| <li>Email: {email}</li> | |
| </ul></div> | |
| <div class="detail-section"><h4>Direct & Inherited Access</h4><ul> | |
| """) | |
| if acct_access: | |
| seen = set() | |
| for a in acct_access: | |
| key = (a["account_id"], a["permission_set_name"], a["principal_name"]) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| parts.append(f' <li>{esc(a["account_name"])} — <span class="badge badge-ps">{esc(a["permission_set_name"])}</span> (via {esc(a["principal_type"].lower())} {esc(a["principal_name"])})</li>\n') | |
| else: | |
| parts.append(" <li>No account access</li>\n") | |
| parts.append(" </ul></div>\n</div></td></tr>\n") | |
| parts.append("</tbody></table></div></div></div>\n") | |
| # ── TAB: Groups ── | |
| parts.append(""" | |
| <div id="tab-groups" class="tab-content"> | |
| <input class="search-bar" type="text" placeholder="Search groups..." onkeyup="filterTable(this, 'groups-table')"> | |
| <div class="table-wrap"><div class="table-scroll"> | |
| <table id="groups-table"> | |
| <thead><tr> | |
| <th onclick="sortTable('groups-table',0)">Group Name <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('groups-table',1)">Description <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('groups-table',2)">Members <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('groups-table',3)">Account Access <span class="sort-icon">⇅</span></th> | |
| <th>Details</th> | |
| </tr></thead> | |
| <tbody> | |
| """) | |
| for g in sorted(groups, key=lambda x: x.get("DisplayName", "")): | |
| gid = g["GroupId"] | |
| gname = esc(g.get("DisplayName", "")) | |
| gdesc = esc(g.get("Description", "")) | |
| members = memberships.get(gid, []) | |
| member_badges = "".join( | |
| f'<span class="badge badge-user">{esc(user_map.get(uid, {}).get("UserName", uid))}</span>' | |
| for uid in members | |
| ) or '<span style="color:var(--text-muted)">None</span>' | |
| grp_access = assignment_by_principal.get(gid, []) | |
| acct_badges = "".join( | |
| f'<span class="badge badge-acct">{esc(a["account_name"])}</span> <span class="badge badge-ps">{esc(a["permission_set_name"])}</span> ' | |
| for a in grp_access | |
| ) or '<span style="color:var(--text-muted)">None</span>' | |
| row_id = f"group-{gid}" | |
| parts.append(f"""<tr> | |
| <td><strong>{gname}</strong></td> | |
| <td>{gdesc}</td> | |
| <td>{member_badges} <span class="count">({len(members)})</span></td> | |
| <td>{acct_badges}</td> | |
| <td><button class="expand-btn" onclick="toggleDetail('{row_id}')">Details</button></td> | |
| </tr> | |
| <tr class="detail-row" id="{row_id}"><td colspan="5"><div class="detail-content"> | |
| <div class="detail-section"><h4>Members ({len(members)})</h4><ul> | |
| """) | |
| for uid in members: | |
| u = user_map.get(uid, {}) | |
| parts.append(f' <li>{esc(u.get("UserName", uid))} — {esc(u.get("DisplayName", ""))}</li>\n') | |
| if not members: | |
| parts.append(" <li>No members</li>\n") | |
| parts.append(f""" </ul></div> | |
| <div class="detail-section"><h4>Account Assignments</h4><ul> | |
| """) | |
| for a in grp_access: | |
| parts.append(f' <li>{esc(a["account_name"])} ({esc(a["account_id"])}) — {esc(a["permission_set_name"])}</li>\n') | |
| if not grp_access: | |
| parts.append(" <li>No assignments</li>\n") | |
| parts.append(" </ul></div>\n</div></td></tr>\n") | |
| parts.append("</tbody></table></div></div></div>\n") | |
| # ── TAB: Permission Sets ── | |
| parts.append(""" | |
| <div id="tab-permsets" class="tab-content"> | |
| <input class="search-bar" type="text" placeholder="Search permission sets..." onkeyup="filterTable(this, 'ps-table')"> | |
| <div class="table-wrap"><div class="table-scroll"> | |
| <table id="ps-table"> | |
| <thead><tr> | |
| <th onclick="sortTable('ps-table',0)">Name <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('ps-table',1)">Description <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('ps-table',2)">Session Duration <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('ps-table',3)">Managed Policies <span class="sort-icon">⇅</span></th> | |
| <th>Details</th> | |
| </tr></thead> | |
| <tbody> | |
| """) | |
| for ps_arn, ps_data in sorted(permission_sets.items(), key=lambda x: x[1]["details"].get("Name", "")): | |
| d = ps_data["details"] | |
| name = esc(d.get("Name", "")) | |
| desc = esc(d.get("Description", "")) | |
| duration = d.get("SessionDuration", "") | |
| managed = ps_data["managed_policies"] | |
| managed_badges = "".join( | |
| f'<span class="badge badge-type">{esc(p.get("Name", p.get("Arn", "")))}</span>' | |
| for p in managed | |
| ) or '<span style="color:var(--text-muted)">None</span>' | |
| row_id = f"ps-{name}" | |
| inline = ps_data["inline_policy"] | |
| customer = ps_data["customer_managed_policies"] | |
| boundary = ps_data["permissions_boundary"] | |
| parts.append(f"""<tr> | |
| <td><strong><span class="badge badge-ps">{name}</span></strong></td> | |
| <td>{desc}</td> | |
| <td>{esc(duration)}</td> | |
| <td>{managed_badges}</td> | |
| <td><button class="expand-btn" onclick="toggleDetail('{row_id}')">Details</button></td> | |
| </tr> | |
| <tr class="detail-row" id="{row_id}"><td colspan="5"><div class="detail-content"> | |
| <div class="detail-section"><h4>ARN</h4><pre>{esc(ps_arn)}</pre></div> | |
| """) | |
| if inline: | |
| try: | |
| formatted = json.dumps(json.loads(inline), indent=2) | |
| except Exception: | |
| formatted = inline | |
| parts.append(f' <div class="detail-section"><h4>Inline Policy</h4><pre>{esc(formatted)}</pre></div>\n') | |
| if managed: | |
| parts.append(' <div class="detail-section"><h4>AWS Managed Policies</h4><ul>\n') | |
| for p in managed: | |
| parts.append(f' <li>{esc(p.get("Name", ""))} — {esc(p.get("Arn", ""))}</li>\n') | |
| parts.append(" </ul></div>\n") | |
| if customer: | |
| parts.append(' <div class="detail-section"><h4>Customer Managed Policies</h4><ul>\n') | |
| for p in customer: | |
| parts.append(f' <li>{esc(p.get("Name", ""))} (path: {esc(p.get("Path", "/"))})</li>\n') | |
| parts.append(" </ul></div>\n") | |
| if boundary: | |
| parts.append(f' <div class="detail-section"><h4>Permissions Boundary</h4><pre>{esc(json.dumps(boundary, indent=2, default=str))}</pre></div>\n') | |
| parts.append("</div></td></tr>\n") | |
| parts.append("</tbody></table></div></div></div>\n") | |
| # ── TAB: Accounts ── | |
| parts.append(""" | |
| <div id="tab-accounts" class="tab-content"> | |
| <input class="search-bar" type="text" placeholder="Search accounts..." onkeyup="filterTable(this, 'accounts-table')"> | |
| <div class="table-wrap"><div class="table-scroll"> | |
| <table id="accounts-table"> | |
| <thead><tr> | |
| <th onclick="sortTable('accounts-table',0)">Account ID <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('accounts-table',1)">Account Name <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('accounts-table',2)">Email <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('accounts-table',3)">Status <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('accounts-table',4)">Assignments <span class="sort-icon">⇅</span></th> | |
| <th>Details</th> | |
| </tr></thead> | |
| <tbody> | |
| """) | |
| for acct_id, acct in sorted(account_map.items(), key=lambda x: x[1].get("Name", "")): | |
| aname = esc(acct.get("Name", "")) | |
| aemail = esc(acct.get("Email", "")) | |
| astatus = esc(acct.get("Status", "")) | |
| acct_assigns = assignment_by_account.get(acct_id, []) | |
| row_id = f"acct-{acct_id}" | |
| parts.append(f"""<tr> | |
| <td><code>{esc(acct_id)}</code></td> | |
| <td><strong>{aname}</strong></td> | |
| <td>{aemail}</td> | |
| <td>{astatus}</td> | |
| <td><span class="count">{len(acct_assigns)} assignments</span></td> | |
| <td><button class="expand-btn" onclick="toggleDetail('{row_id}')">Details</button></td> | |
| </tr> | |
| <tr class="detail-row" id="{row_id}"><td colspan="6"><div class="detail-content"> | |
| <div class="detail-section"><h4>Principals with Access</h4><ul> | |
| """) | |
| for a in acct_assigns: | |
| ptype_badge = "badge-user" if a["principal_type"] == "USER" else "badge-group" | |
| parts.append( | |
| f' <li><span class="badge {ptype_badge}">{esc(a["principal_type"])}</span> ' | |
| f'{esc(a["principal_name"])} → <span class="badge badge-ps">{esc(a["permission_set_name"])}</span></li>\n' | |
| ) | |
| if not acct_assigns: | |
| parts.append(" <li>No assignments</li>\n") | |
| parts.append(" </ul></div>\n</div></td></tr>\n") | |
| parts.append("</tbody></table></div></div></div>\n") | |
| # ── TAB: All Assignments ── | |
| parts.append(""" | |
| <div id="tab-assignments" class="tab-content"> | |
| <input class="search-bar" type="text" placeholder="Search assignments..." onkeyup="filterTable(this, 'assign-table')"> | |
| <div class="table-wrap"><div class="table-scroll"> | |
| <table id="assign-table"> | |
| <thead><tr> | |
| <th onclick="sortTable('assign-table',0)">Account <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('assign-table',1)">Principal Type <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('assign-table',2)">Principal <span class="sort-icon">⇅</span></th> | |
| <th onclick="sortTable('assign-table',3)">Permission Set <span class="sort-icon">⇅</span></th> | |
| </tr></thead> | |
| <tbody> | |
| """) | |
| all_assign_entries = [] | |
| for acct_assigns in assignment_by_account.values(): | |
| all_assign_entries.extend(acct_assigns) | |
| for a in sorted(all_assign_entries, key=lambda x: (x["account_name"], x["principal_type"], x["principal_name"])): | |
| ptype_badge = "badge-user" if a["principal_type"] == "USER" else "badge-group" | |
| parts.append(f"""<tr> | |
| <td><span class="badge badge-acct">{esc(a["account_name"])}</span> <span class="count">{esc(a["account_id"])}</span></td> | |
| <td><span class="badge {ptype_badge}">{esc(a["principal_type"])}</span></td> | |
| <td>{esc(a["principal_name"])}</td> | |
| <td><span class="badge badge-ps">{esc(a["permission_set_name"])}</span></td> | |
| </tr> | |
| """) | |
| parts.append("</tbody></table></div></div></div>\n") | |
| # ── TAB: Access Matrix ── | |
| # Rows = users, Columns = accounts, Cells = permission sets (direct + inherited) | |
| parts.append(""" | |
| <div id="tab-matrix" class="tab-content"> | |
| <input class="search-bar" type="text" placeholder="Search matrix..." onkeyup="filterTable(this, 'matrix-table')"> | |
| <p style="color:var(--text-muted);font-size:13px;margin-bottom:12px;">Effective access per user (direct + group-inherited). Cells show permission set names.</p> | |
| <div class="table-wrap"><div class="table-scroll"> | |
| <table id="matrix-table"> | |
| <thead><tr> | |
| <th>User</th> | |
| """) | |
| sorted_accts = sorted(account_map.items(), key=lambda x: x[1].get("Name", "")) | |
| for acct_id, acct in sorted_accts: | |
| parts.append(f' <th style="font-size:11px;writing-mode:vertical-lr;text-orientation:mixed;max-width:40px;">{esc(acct.get("Name", acct_id))}</th>\n') | |
| parts.append(" </tr></thead>\n <tbody>\n") | |
| for u in sorted(users, key=lambda x: x.get("UserName", "")): | |
| uid = u["UserId"] | |
| uname = esc(u.get("UserName", "")) | |
| # Compute effective access: direct + via group memberships | |
| effective = defaultdict(set) # account_id -> set of ps_names | |
| for a in assignment_by_principal.get(uid, []): | |
| effective[a["account_id"]].add(a["permission_set_name"]) | |
| for gid in user_groups.get(uid, []): | |
| for a in assignment_by_principal.get(gid, []): | |
| effective[a["account_id"]].add(a["permission_set_name"]) | |
| parts.append(f' <tr><td><strong>{uname}</strong></td>\n') | |
| for acct_id, _ in sorted_accts: | |
| ps_set = effective.get(acct_id, set()) | |
| if ps_set: | |
| cell = " ".join(f'<span class="badge badge-ps" style="font-size:10px;">{esc(p)}</span>' for p in sorted(ps_set)) | |
| else: | |
| cell = '<span style="color:#30363d;">—</span>' | |
| parts.append(f' <td>{cell}</td>\n') | |
| parts.append(" </tr>\n") | |
| parts.append("</tbody></table></div></div></div>\n") | |
| # ── JavaScript ── | |
| parts.append(""" | |
| </div><!-- container --> | |
| <script> | |
| // Tab switching | |
| document.querySelectorAll('.tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); | |
| tab.classList.add('active'); | |
| document.getElementById(tab.dataset.tab).classList.add('active'); | |
| }); | |
| }); | |
| // Detail row toggle | |
| function toggleDetail(id) { | |
| const row = document.getElementById(id); | |
| if (row) row.classList.toggle('show'); | |
| } | |
| // Table search/filter | |
| function filterTable(input, tableId) { | |
| const filter = input.value.toLowerCase(); | |
| const table = document.getElementById(tableId); | |
| const rows = table.querySelectorAll('tbody tr:not(.detail-row)'); | |
| rows.forEach(row => { | |
| const text = row.textContent.toLowerCase(); | |
| const match = text.includes(filter); | |
| row.style.display = match ? '' : 'none'; | |
| // hide detail row too | |
| const next = row.nextElementSibling; | |
| if (next && next.classList.contains('detail-row')) { | |
| if (!match) next.classList.remove('show'); | |
| next.style.display = match ? '' : 'none'; | |
| } | |
| }); | |
| } | |
| // Column sorting | |
| let sortState = {}; | |
| function sortTable(tableId, colIdx) { | |
| const table = document.getElementById(tableId); | |
| const tbody = table.querySelector('tbody'); | |
| const rows = Array.from(tbody.querySelectorAll('tr:not(.detail-row)')); | |
| // Determine direction | |
| const key = tableId + '-' + colIdx; | |
| sortState[key] = sortState[key] === 'asc' ? 'desc' : 'asc'; | |
| const dir = sortState[key] === 'asc' ? 1 : -1; | |
| // Update header styling | |
| table.querySelectorAll('th').forEach(th => th.classList.remove('sorted')); | |
| table.querySelectorAll('th')[colIdx]?.classList.add('sorted'); | |
| rows.sort((a, b) => { | |
| const aText = a.cells[colIdx]?.textContent.trim().toLowerCase() || ''; | |
| const bText = b.cells[colIdx]?.textContent.trim().toLowerCase() || ''; | |
| return aText.localeCompare(bText) * dir; | |
| }); | |
| // Rebuild tbody preserving detail rows | |
| const frag = document.createDocumentFragment(); | |
| rows.forEach(row => { | |
| frag.appendChild(row); | |
| const next = row.nextElementSibling; | |
| // Find matching detail row by checking original DOM | |
| const detailId = row.querySelector('.expand-btn')?.getAttribute('onclick')?.match(/'([^']+)'/)?.[1]; | |
| if (detailId) { | |
| const detailRow = document.getElementById(detailId); | |
| if (detailRow) frag.appendChild(detailRow); | |
| } | |
| }); | |
| tbody.appendChild(frag); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """) | |
| return "".join(parts) | |
| # ─── Main ─────────────────────────────────────────────────────────────────── | |
| def main(): | |
| print("=" * 60) | |
| print(" AWS Identity Center — Comprehensive Report Generator") | |
| print("=" * 60) | |
| print() | |
| session = boto3.Session() | |
| sso_admin = session.client("sso-admin") | |
| identity_store = session.client("identitystore") | |
| org_client = session.client("organizations") | |
| # 1. Discover instance | |
| print("[1/7] Discovering SSO instance...") | |
| instance_arn, store_id = get_sso_instance(sso_admin) | |
| print(f" Instance ARN: {instance_arn}") | |
| print(f" Identity Store ID: {store_id}") | |
| print() | |
| # 2. Accounts | |
| print("[2/7] Collecting accounts...") | |
| account_map = collect_accounts(org_client) | |
| print() | |
| # 3. Users | |
| print("[3/7] Collecting users...") | |
| users = collect_users(identity_store, store_id) | |
| user_map = {u["UserId"]: u for u in users} | |
| print() | |
| # 4. Groups | |
| print("[4/7] Collecting groups...") | |
| groups = collect_groups(identity_store, store_id) | |
| group_map = {g["GroupId"]: g for g in groups} | |
| print() | |
| # 5. Memberships | |
| print("[5/7] Collecting group memberships...") | |
| memberships = collect_group_memberships(identity_store, store_id, groups, user_map) | |
| print() | |
| # 6. Permission sets | |
| print("[6/7] Collecting permission sets...") | |
| permission_sets = collect_permission_sets(sso_admin, instance_arn) | |
| print() | |
| # 7. Account assignments | |
| print("[7/7] Collecting account assignments...") | |
| assignments = collect_account_assignments(sso_admin, instance_arn, permission_sets, account_map) | |
| print() | |
| # Generate report | |
| print("Generating HTML report...") | |
| html_content = generate_html( | |
| users, groups, memberships, permission_sets, | |
| assignments, account_map, user_map, group_map | |
| ) | |
| output_file = "aws_identity_center_report.html" | |
| with open(output_file, "w", encoding="utf-8") as f: | |
| f.write(html_content) | |
| print() | |
| print("=" * 60) | |
| print(f" Report saved to: {output_file}") | |
| print(f" Open in browser: file://{output_file}") | |
| print("=" * 60) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment