"""Reader functions for Auth0 exported data files.""" import json from pathlib import Path from typing import Any, Iterator from src.models import Auth0User, CombinedUser, Neo4jIdentity, Organization, OrgMember, Profile, TenantRelationship, VendContactRecord, VendContactReport def read_auth0_users(file_path: str) -> Iterator[Auth0User]: """ Read Auth0 user export file line by line and yield Auth0User objects. Args: file_path: Path to the JSON Lines file Yields: Auth0User object for each parsed JSON line """ path = Path(file_path) if not path.exists(): raise FileNotFoundError(f'File not found: {file_path}') with open(path, 'r', encoding='utf-8') as f: for line_number, line in enumerate(f, start=1): line = line.strip() if not line: continue try: user_data = json.loads(line) raw_vend_contact_id = user_data.get('Vend Contact Id', '') vend_contact_id = int(raw_vend_contact_id) if raw_vend_contact_id else None user = Auth0User( id=user_data.get('Id', ''), nickname=user_data.get('Nickname', ''), name=user_data.get('Name', ''), email=user_data.get('Email', ''), email_verified=user_data.get('Email Verified', False), connection=user_data.get('Connection', ''), created_at=user_data.get('Created At', ''), updated_at=user_data.get('Updated At', ''), last_login=user_data.get('Last Login', ''), identity_id=user_data.get('Identity Id', ''), vend_contact_id=vend_contact_id, ) yield user except json.JSONDecodeError as e: print(f'Warning: Failed to parse line {line_number}: {e}') continue def read_auth0_org_members(file_path: str) -> list[OrgMember]: """ Read org members JSON file and return a canonical list of users with their organizations. The input file has structure: { org_id: { name, display_name, members: [...] }, ... } This function inverts it to: user -> list of organizations they belong to. Args: file_path: Path to the org members JSON file Returns: List of OrgMember objects, each containing user info and their organization memberships """ path = Path(file_path) if not path.exists(): raise FileNotFoundError(f'File not found: {file_path}') with open(path, 'r', encoding='utf-8') as f: data = json.load(f) # Build a map of user_id -> (user_info, list of orgs) user_orgs: dict[str, tuple[dict[str, str], list[Organization]]] = {} for org_id, org_data in data.items(): org = Organization( id=org_id, name=org_data.get('name', ''), display_name=org_data.get('display_name', ''), ) for member in org_data.get('members', []): user_id = member.get('user_id', '') if not user_id: continue if user_id not in user_orgs: user_orgs[user_id] = ( {'email': member.get('email', ''), 'name': member.get('name', '')}, [], ) user_orgs[user_id][1].append(org) # Convert to list of OrgMember objects return [ OrgMember( user_id=user_id, email=user_info['email'], name=user_info['name'], organizations=orgs, ) for user_id, (user_info, orgs) in user_orgs.items() ] def _parse_profile(profile_data: dict[str, Any]) -> Profile: """Parse a profile dict from Neo4j JSON into a Profile object.""" relationships = [ TenantRelationship( relationship_type=rel.get('relationshipType', ''), tenant_id=rel.get('tenantId'), tenant_uuid=rel.get('tenantUuid', ''), tenant_name=rel.get('tenantName', ''), tenant_labels=rel.get('tenantLabels', []), ) for rel in profile_data.get('relationships', []) ] return Profile( uuid=profile_data.get('uuid', ''), profile_type=profile_data.get('profileType', ''), profile_id=profile_data.get('profileId'), full_catalog_access=profile_data.get('fullCatalogAccess', False), brand=profile_data.get('brand', ''), relationships=relationships, ) def _count_access_relationships(profiles: list[Profile]) -> tuple[int, int]: """ Count active and deleted access relationships across all profiles. Returns: Tuple of (active_access_count, deleted_access_count) """ active_access_types = {'HAS_ACCESS_TO', 'HAS_ADMIN_ACCESS_TO'} deleted_access_types = {'DELETED_HAS_ACCESS_TO', 'DELETED_HAS_ADMIN_ACCESS_TO'} active_count = 0 deleted_count = 0 for profile in profiles: for rel in profile.relationships: if rel.relationship_type in active_access_types: active_count += 1 elif rel.relationship_type in deleted_access_types: deleted_count += 1 return active_count, deleted_count def read_neo4j_identities(file_path: str) -> list[Neo4jIdentity]: """ Read Neo4j identity export JSON file and return a list of Neo4jIdentity objects. Args: file_path: Path to the JSON file containing an array of identity objects Returns: List of Neo4jIdentity objects """ path = Path(file_path) if not path.exists(): raise FileNotFoundError(f'File not found: {file_path}') with open(path, 'r', encoding='utf-8') as f: data = json.load(f) identities = [] for item in data: # Parse profiles if present profiles_data = item.get('profiles', []) profiles = [_parse_profile(p) for p in profiles_data] if profiles_data else [] # Compute access counts from profiles active_access_count, deleted_access_count = _count_access_relationships(profiles) identities.append( Neo4jIdentity( id=item.get('id', ''), first_name=item.get('firstName', ''), last_name=item.get('lastName', ''), name=item.get('name', ''), email=item.get('email', ''), default_brand=item.get('defaultBrand', ''), has_active_access=item.get('hasActiveAccess', False), auth0_user_id=item.get('auth0UserId', ''), original_auth0_id=item.get('originalAuth0Id', ''), active=item.get('active', ''), is_employee=item.get('isEmployee'), found=item.get('found', False), # Audit fields updated_by=item.get('updatedBy', ''), updated_on=item.get('updatedOn', ''), created_at=item.get('createdAt', ''), last_modified_by=item.get('lastModifiedBy', ''), last_modified_at=item.get('lastModifiedAt', ''), # Profiles and computed counts profiles=profiles, profile_count=len(profiles), active_access_count=active_access_count, deleted_access_count=deleted_access_count, ) ) return identities def read_vend_contact_report(file_path: str) -> dict[str, VendContactReport]: """ Read vend_contact report JSON and return a dict keyed by auth0_id. Args: file_path: Path to the JSON file from export_vend_contacts.py Returns: Dict mapping auth0_id -> VendContactReport """ path = Path(file_path) if not path.exists(): raise FileNotFoundError(f'File not found: {file_path}') with open(path, 'r', encoding='utf-8') as f: data = json.load(f) result: dict[str, VendContactReport] = {} for item in data: auth0_id = item['auth0_id'] vend_contacts = [ VendContactRecord( id=vc['id'], auth0_user_id=vc.get('auth0_user_id'), auth0_primary=vc.get('auth0_primary'), active=vc.get('active', 'Y'), ) for vc in item.get('vend_contacts', []) ] result[auth0_id] = VendContactReport( mismatch=item.get('mismatch', False), label_profile_ids=item.get('label_profile_ids', []), vend_contacts=vend_contacts, ) return result def combine_user_data( auth0_users_file: str, org_members_file: str, neo4j_identities_file: str, vend_contact_report_file: str | None = None, ) -> list[CombinedUser]: """ Combine data from Auth0 users, org members, Neo4j identities, and vend_contact report. Matching strategy: - Auth0User.id <-> OrgMember.user_id (for organization memberships) - Auth0User.identity_id <-> Neo4jIdentity.id (for Neo4j identity data) - Auth0User.id <-> VendContactReport key (for vend_contact report data) Args: auth0_users_file: Path to the Auth0 user export JSON Lines file org_members_file: Path to the org members JSON file neo4j_identities_file: Path to the Neo4j identities JSON file vend_contact_report_file: Path to vend_contact report JSON file (optional) Returns: List of CombinedUser objects combining all data sources """ # Read all data sources auth0_users = list(read_auth0_users(auth0_users_file)) org_members = read_auth0_org_members(org_members_file) neo4j_identities = read_neo4j_identities(neo4j_identities_file) vend_contact_report = read_vend_contact_report(vend_contact_report_file) if vend_contact_report_file else {} # Build lookup dicts for efficient matching org_members_by_user_id: dict[str, OrgMember] = {member.user_id: member for member in org_members} neo4j_by_identity_id: dict[str, Neo4jIdentity] = {identity.id: identity for identity in neo4j_identities} # Combine data for each Auth0 user combined_users: list[CombinedUser] = [] for user in auth0_users: # Match org memberships by user_id and attach to Auth0User org_member = org_members_by_user_id.get(user.id) user.organizations = org_member.organizations if org_member else [] # Match Neo4j identity by identity_id (None if not found) neo4j_identity = neo4j_by_identity_id.get(user.identity_id) # Match vend_contact report by auth0_id (None if not found) vc_report = vend_contact_report.get(user.id) combined_user = CombinedUser( auth0_data=user, neo4j_data=neo4j_identity, mysql_data=vc_report, ) combined_users.append(combined_user) return combined_users