#!/usr/bin/env python3 """ Auth0 Organization Members Fetcher Fetches members for all Auth0 organizations with support for: - Interactive file picker for organization files - Test mode (single org, limited users) - Full run with rate limiting and pagination """ import argparse import json import logging import subprocess import time from datetime import datetime from pathlib import Path from typing import Any from src.auth0.auth import check_auth0_login from src.models import Organization logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) def format_file_size(size_bytes: int) -> str: """Format bytes as human-readable string (KB or MB).""" if size_bytes < 1024 * 1024: return f'{size_bytes / 1024:.1f} KB' return f'{size_bytes / (1024 * 1024):.1f} MB' def list_org_files(directory: str = 'data/input') -> list[tuple[Path, datetime, int]]: """Find orgs_*.json files with modification date and size. Returns list of (path, mtime, size) tuples sorted by date (newest first). """ input_dir = Path(directory) if not input_dir.exists(): return [] files: list[tuple[Path, datetime, int]] = [] for f in input_dir.glob('orgs_*.json'): stat = f.stat() files.append((f, datetime.fromtimestamp(stat.st_mtime), stat.st_size)) return sorted(files, key=lambda x: x[1], reverse=True) def prompt_file_selection(files: list[tuple[Path, datetime, int]]) -> Path: """Display numbered menu of files and return selected path.""" print('\nAvailable organization files in data/input/:') for i, (path, mtime, size) in enumerate(files, 1): date_str = mtime.strftime('%Y-%m-%d %H:%M:%S') print(f' {i}) {path.name} ({date_str}, {format_file_size(size)})') while True: choice = input(f'\nSelect file (1-{len(files)}): ').strip() if choice.isdigit() and 1 <= int(choice) <= len(files): return files[int(choice) - 1][0] print(f'Invalid choice. Enter a number between 1 and {len(files)}.') def read_organizations(file_path: Path) -> list[Organization]: """Parse JSON array into Organization dataclasses.""" with open(file_path, 'r') as f: data = json.load(f) orgs: list[Organization] = [] for item in data: orgs.append(Organization(id=item['id'], name=item['name'], display_name=item['display_name'])) return orgs def prompt_org_selection(orgs: list[Organization]) -> Organization: """Display numbered list of organizations and return selected one.""" print('\nAvailable organizations:') for i, org in enumerate(orgs, 1): print(f' {i}) {org.display_name} ({org.id})') while True: choice = input(f'\nSelect organization to test (1-{len(orgs)}): ').strip() if choice.isdigit() and 1 <= int(choice) <= len(orgs): return orgs[int(choice) - 1] print(f'Invalid choice. Enter a number between 1 and {len(orgs)}.') def prompt_member_limit() -> int: """Ask how many members to fetch (default: 10).""" while True: choice = input('\nHow many members to fetch? (default: 10): ').strip() if not choice: return 10 if choice.isdigit() and int(choice) > 0: return int(choice) print('Invalid input. Enter a positive number.') def fetch_org_members(org_id: str, limit: int | None = None, page_size: int = 100, delay: float = 0.5) -> list[dict[str, Any]]: """Fetch members for an organization using Auth0 API with checkpoint pagination. Uses the raw Management API via 'auth0 api' command to bypass the CLI's 1000 member limit. Implements checkpoint pagination using the 'next' token. Args: org_id: The Auth0 organization ID limit: Maximum number of members to fetch (None for all) page_size: Number of members per API call (max 100) delay: Seconds to sleep between API calls for rate limiting Returns: List of member dictionaries with all fields from API """ all_members: list[dict[str, Any]] = [] next_token: str | None = None page = 0 while True: page += 1 # Build API endpoint with checkpoint pagination endpoint = f'organizations/{org_id}/members?take={page_size}' if next_token: endpoint += f'&from={next_token}' logger.info(f' Page {page}: GET {endpoint}') cmd = ['auth0', 'api', endpoint] try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) response = json.loads(result.stdout) # API returns object with 'members' array and optional 'next' token members = response.get('members', []) if isinstance(response, dict) else response all_members.extend(members) logger.info(f' Page {page}: fetched {len(members)} members (total: {len(all_members)})') # Check if we've reached the limit if limit and len(all_members) >= limit: return all_members[:limit] # Get next token for pagination next_token = response.get('next') if isinstance(response, dict) else None if not next_token: logger.info(f' Pagination complete: {len(all_members)} total members') break # Rate limiting between API calls logger.info(f' Waiting {delay}s before next page...') time.sleep(delay) except subprocess.CalledProcessError as e: logger.error(f'Error fetching members for {org_id}: {e.stderr}') return all_members # Return what we have so far except json.JSONDecodeError as e: logger.error(f'Error parsing response for {org_id}: {e}') return all_members # Return what we have so far return all_members def fetch_all_org_members( orgs: list[Organization], page_size: int = 100, delay: float = 5, org_delay: float = 2.0, ) -> dict[str, dict[str, Any]]: """Fetch members for all organizations. Returns dict with org_id as key and {name, display_name, members} as value. """ results: dict[str, dict[str, Any]] = {} for i, org in enumerate(orgs, 1): logger.info(f'Fetching org {i}/{len(orgs)}: {org.display_name} ({org.id})') members = fetch_org_members(org.id, page_size=page_size, delay=delay) results[org.id] = {'name': org.name, 'display_name': org.display_name, 'members': members} logger.info(f' Fetched {len(members)} members') # Sleep between organizations (except for the last one) if i < len(orgs): time.sleep(org_delay) return results def main() -> None: """Main entry point.""" parser = argparse.ArgumentParser(description='Fetch Auth0 organization members') parser.add_argument('--input', help='Path to org JSON file (interactive prompt if omitted)') parser.add_argument('--test', action='store_true', help='Test mode: single org, limited users') parser.add_argument('--output', help='Path to output file') parser.add_argument('--page-size', type=int, default=100, help='Members per API call (default: 100)') parser.add_argument('--delay', type=float, default=0.75, help='Delay between API calls in seconds (default: 0.5)') args = parser.parse_args() # Check Auth0 login check_auth0_login() # Select input file if args.input: input_file = Path(args.input) else: org_files = list_org_files() if not org_files: logger.error('No orgs_*.json files found in data/input/') return input_file = prompt_file_selection(org_files) logger.info(f'Reading organizations from {input_file}') orgs = read_organizations(input_file) logger.info(f'Found {len(orgs)} organizations') # Ensure output directory exists output_dir = Path('data/input') output_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') if args.test: # Test mode: single org, limited users selected_org = prompt_org_selection(orgs) member_limit = prompt_member_limit() logger.info(f'Test mode: fetching {member_limit} members from {selected_org.display_name}') members = fetch_org_members(selected_org.id, limit=member_limit, page_size=args.page_size, delay=args.delay) results = { selected_org.id: { 'name': selected_org.name, 'display_name': selected_org.display_name, 'members': members, } } # Output file for test mode output_file = args.output or f'data/input/org_members_{selected_org.name}_test_{timestamp}.json' else: # Full run: all organizations logger.info('Full run: fetching members from all organizations') results = fetch_all_org_members(orgs, page_size=args.page_size, delay=args.delay) # Output file for full run output_file = args.output or f'data/input/org_members_{timestamp}.json' # Write results with open(output_file, 'w') as f: json.dump(results, f, indent=2) # Summary total_members = sum(len(org_data['members']) for org_data in results.values()) logger.info('=' * 60) logger.info('Fetch Complete!') logger.info(f'Organizations processed: {len(results)}') logger.info(f'Total members fetched: {total_members}') logger.info(f'Results written to: {output_file}') logger.info('=' * 60) if __name__ == '__main__': main()