import argparse import json import os import sys from typing import Any, Dict, List, Optional, Sequence from urllib.parse import quote import connector_neo4j import requests REQUIRED_NEO_ENV_VARS = ("NEO_URL", "NEO_USER", "NEO_PASSWORD") REQUIRED_AUTH0_ENV_VARS = ( "AUTH0_DOMAIN", "AUTH0_CLI_MACHINE_CLIENT_ID", "AUTH0_CLI_MACHINE_CLIENT_SECRET", ) ANSI_RESET = "\033[0m" ANSI_CYAN = "\033[36m" ANSI_BLUE = "\033[94m" ANSI_MAGENTA = "\033[95m" ANSI_GREEN = "\033[92m" ANSI_YELLOW = "\033[93m" ANSI_RED = "\033[91m" USE_COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Check Identity records in Neo4j and Auth0 for one or more emails." ) parser.add_argument( "emails", metavar="EMAIL", nargs="+", help="One or more full emails to look up.", ) return parser.parse_args() def require_env_vars(keys: Sequence[str]) -> None: missing = [key for key in keys if not os.environ.get(key)] if missing: raise RuntimeError("Missing required environment variable(s): " + ", ".join(missing)) def colorize(text: str, color_code: str) -> str: if not USE_COLOR: return text return f"{color_code}{text}{ANSI_RESET}" def print_field(label: str, value: Any, indent: str = " ") -> None: print(f"{indent}{colorize(f'{label}:', ANSI_CYAN)} {str(value)}") def auth0_access_token() -> str: domain = os.environ["AUTH0_DOMAIN"] base_url = f"https://{domain}" payload = { "grant_type": "client_credentials", "client_id": os.environ["AUTH0_CLI_MACHINE_CLIENT_ID"], "client_secret": os.environ["AUTH0_CLI_MACHINE_CLIENT_SECRET"], "audience": f"{base_url}/api/v2/", } response = requests.post(f"{base_url}/oauth/token", data=payload, timeout=30) if response.status_code >= 300: raise RuntimeError( f"Auth0 token request failed with status {response.status_code}: {response.text}" ) token = response.json().get("access_token") if not token: raise RuntimeError("Auth0 token response did not include access_token.") return token def get_auth0_users_by_email(email: str, headers: Dict[str, str], base_url: str) -> List[Dict[str, Any]]: response = requests.get( f"{base_url}/api/v2/users-by-email", params={"email": email}, headers=headers, timeout=30, ) if response.status_code >= 300: raise RuntimeError( f"Auth0 users-by-email request failed with status {response.status_code}: {response.text}" ) payload = response.json() if isinstance(payload, list): return payload return [] def get_auth0_organizations_for_user( auth0_user_id: str, headers: Dict[str, str], base_url: str ) -> List[Dict[str, Any]]: encoded_auth0_user_id = quote(auth0_user_id, safe="") response = requests.get( f"{base_url}/api/v2/users/{encoded_auth0_user_id}/organizations", headers=headers, timeout=30, ) if response.status_code >= 300: raise RuntimeError( f"Auth0 organizations request failed with status {response.status_code}: {response.text}" ) payload = response.json() if isinstance(payload, list): return payload return [] @connector_neo4j.Neo4jSession() def get_neo_identities_by_email(email: str) -> List[Dict[str, Any]]: session = connector_neo4j.get_session() query = """ MATCH (i:Identity) WHERE toLower(i.email) = toLower($email) RETURN i ORDER BY i.createdAt DESC """ rows = session.run(query, email=email) return [dict(record["i"]) for record in rows] @connector_neo4j.Neo4jSession() def get_neo_profiles_for_identity(identity_id: str) -> List[Dict[str, Any]]: session = connector_neo4j.get_session() query = """ MATCH (i:Identity)-[:HAS_PROFILE]->(p:Profile) WHERE i.id = $identity_id OPTIONAL MATCH (p)-[rel]-(resource) WHERE resource:Vendor OR resource:SubAccount WITH p, collect( DISTINCT CASE WHEN rel IS NULL OR resource IS NULL THEN NULL ELSE { relationship: type(rel), resourceName: coalesce( resource.name, resource.displayName, resource.display_name, resource.profileName, resource.id, resource.uuid ) } END ) AS relationship_entries RETURN p, [entry IN relationship_entries WHERE entry IS NOT NULL] AS relationship_entries ORDER BY p.profileType, p.profileName, p.name, p.profileId """ rows = session.run(query, identity_id=identity_id) profiles: List[Dict[str, Any]] = [] for record in rows: profile = dict(record["p"]) profile["_resource_relationships"] = record["relationship_entries"] or [] profiles.append(profile) return profiles def profile_display_name(profile: Dict[str, Any]) -> str: for key in ("profileName", "name", "displayName"): value = profile.get(key) if value: return str(value) if profile.get("profileId") is not None: return str(profile["profileId"]) if profile.get("uuid"): return str(profile["uuid"]) return "N/A" def profile_relationship_snippets(profile: Dict[str, Any]) -> List[str]: relationships = profile.get("_resource_relationships") or [] snippets = [] for relationship in relationships: rel_name = relationship.get("relationship") resource_name = relationship.get("resourceName") if not rel_name or not resource_name: continue snippets.append(f":{rel_name} {resource_name}") return list(dict.fromkeys(snippets)) def auth0_identity_id(user: Dict[str, Any]) -> Optional[str]: candidate_keys = ( "orchardIdentityId", "identityId", "identity_id", "orchard_identity_id", ) for metadata_key in ("user_metadata", "app_metadata"): metadata = user.get(metadata_key) or {} for key in candidate_keys: value = metadata.get(key) if value: return str(value) return None def print_neo_data( identities: List[Dict[str, Any]], profiles_by_identity_id: Optional[Dict[str, List[Dict[str, Any]]]] = None, profile_errors_by_identity_id: Optional[Dict[str, str]] = None, ) -> None: profiles_by_identity_id = profiles_by_identity_id or {} profile_errors_by_identity_id = profile_errors_by_identity_id or {} print(colorize("Neo4j:", ANSI_MAGENTA)) if not identities: print(" " + colorize("MISSING", ANSI_YELLOW)) return for index, identity in enumerate(identities, start=1): identity_id = str(identity.get("id", "")) print(f" {colorize(f'Record {index}:', ANSI_BLUE)}") print_field("id", identity_id or "N/A") print_field("name", identity.get("name", "N/A")) print_field("email", identity.get("email", "N/A")) print_field("auth0UserId", identity.get("auth0UserId", "N/A")) print_field("active", identity.get("active", "N/A")) print_field("isEmployee", identity.get("isEmployee", "N/A")) print_field("defaultBrand", identity.get("defaultBrand", "N/A")) print_field("createdAt", identity.get("createdAt", "N/A")) profile_error = profile_errors_by_identity_id.get(identity_id) identity_profiles = profiles_by_identity_id.get(identity_id, []) if profile_error: print_field("profiles error", profile_error) elif identity_profiles: print(f" {colorize('profiles:', ANSI_CYAN)}") for profile in identity_profiles: profile_type = profile.get("profileType", "N/A") profile_name = profile_display_name(profile) profile_id = profile.get("profileId", "N/A") profile_line = f" - {profile_type}: {profile_name} (id: {profile_id})" relationship_snippets = profile_relationship_snippets(profile) if relationship_snippets: profile_line += " " + ", ".join(relationship_snippets) print(profile_line) else: print_field("profiles", "none") def pretty_json(value: Any) -> str: try: return json.dumps(value, indent=2, sort_keys=True, default=str) except TypeError: return str(value) def print_auth0_data( users: List[Dict[str, Any]], organizations_by_user_id: Optional[Dict[str, List[Dict[str, Any]]]] = None, organization_errors_by_user_id: Optional[Dict[str, str]] = None, ) -> None: organizations_by_user_id = organizations_by_user_id or {} organization_errors_by_user_id = organization_errors_by_user_id or {} print(colorize("Auth0:", ANSI_MAGENTA)) if not users: print(" " + colorize("MISSING", ANSI_YELLOW)) return for index, user in enumerate(users, start=1): auth0_user_id = str(user.get("user_id", "")) identity_id = auth0_identity_id(user) print(f" {colorize(f'Record {index}:', ANSI_BLUE)}") print_field("user_id", auth0_user_id or "N/A") print_field("email", user.get("email", "N/A")) print_field("orchard identity id", identity_id or "N/A") print_field("last_login", user.get("last_login", "N/A")) print_field("logins_count", user.get("logins_count", "N/A")) print(f" {colorize('user_metadata:', ANSI_CYAN)}") metadata_json = pretty_json(user.get("user_metadata") or {}) for line in metadata_json.splitlines(): print(f" {line}") organization_error = organization_errors_by_user_id.get(auth0_user_id) organizations = organizations_by_user_id.get(auth0_user_id, []) if organization_error: print_field("auth0 organizations error", organization_error) elif organizations: print(f" {colorize('auth0 organizations:', ANSI_CYAN)}") for organization in organizations: display_name = ( organization.get("display_name") or organization.get("name") or organization.get("id") or "N/A" ) organization_id = organization.get("id") if organization_id and organization_id != display_name: print(f" - {display_name} ({organization_id})") else: print(f" - {display_name}") else: print_field("auth0 organizations", "none") def print_status(neo_identities: List[Dict[str, Any]], auth0_users: List[Dict[str, Any]]) -> None: neo_missing = len(neo_identities) == 0 auth0_missing = len(auth0_users) == 0 if neo_missing and auth0_missing: print(f"{colorize('Result:', ANSI_CYAN)} {colorize('missing in Neo4j and Auth0.', ANSI_YELLOW)}") return if neo_missing: print(f"{colorize('Result:', ANSI_CYAN)} {colorize('found in Auth0, missing in Neo4j.', ANSI_YELLOW)}") return if auth0_missing: print(f"{colorize('Result:', ANSI_CYAN)} {colorize('found in Neo4j, missing in Auth0.', ANSI_YELLOW)}") return neo_ids = {str(identity.get("id")) for identity in neo_identities if identity.get("id")} auth0_ids = set() for user in auth0_users: value = auth0_identity_id(user) if value: auth0_ids.add(value) if not auth0_ids: print( f"{colorize('Result:', ANSI_CYAN)} " f"{colorize('found in both systems, but Auth0 identity id metadata is missing.', ANSI_YELLOW)}" ) return if neo_ids == auth0_ids: print(f"{colorize('Result:', ANSI_CYAN)} {colorize('identity ids match.', ANSI_GREEN)}") return print(f"{colorize('Result:', ANSI_CYAN)} {colorize('DISCREPANCY - identity ids do not match.', ANSI_RED)}") print(" " + colorize("Neo4j identity ids:", ANSI_CYAN) + " " + (", ".join(sorted(neo_ids)) if neo_ids else "none")) print(" " + colorize("Auth0 identity ids:", ANSI_CYAN) + " " + (", ".join(sorted(auth0_ids)) if auth0_ids else "none")) def close_neo4j_driver() -> None: driver = getattr(connector_neo4j, "neo4j_driver", None) if not driver: return try: driver.close() finally: connector_neo4j.neo4j_driver = None connector_neo4j.neo4j_session = None def main() -> int: args = parse_args() require_env_vars(REQUIRED_NEO_ENV_VARS + REQUIRED_AUTH0_ENV_VARS) connector_neo4j.configure( os.environ["NEO_URL"], os.environ["NEO_USER"], os.environ["NEO_PASSWORD"], connector_neo4j.SessionStorageMode.SINGLE, ) try: base_url = f"https://{os.environ['AUTH0_DOMAIN']}" token = auth0_access_token() headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8", } for email in args.emails: print("=" * 72) print(f"{colorize('Email:', ANSI_CYAN)} {email}") neo_error = None auth0_error = None neo_identities: List[Dict[str, Any]] = [] neo_profiles_by_identity_id: Dict[str, List[Dict[str, Any]]] = {} neo_profile_errors_by_identity_id: Dict[str, str] = {} auth0_users: List[Dict[str, Any]] = [] auth0_organizations_by_user_id: Dict[str, List[Dict[str, Any]]] = {} auth0_organization_errors_by_user_id: Dict[str, str] = {} try: neo_identities = get_neo_identities_by_email(email) except Exception as exc: # pragma: no cover neo_error = str(exc) if not neo_error: for identity in neo_identities: identity_id = identity.get("id") if not identity_id: continue identity_id = str(identity_id) try: neo_profiles_by_identity_id[identity_id] = get_neo_profiles_for_identity( identity_id ) except Exception as exc: # pragma: no cover neo_profile_errors_by_identity_id[identity_id] = str(exc) try: auth0_users = get_auth0_users_by_email(email, headers, base_url) except Exception as exc: # pragma: no cover auth0_error = str(exc) if not auth0_error: for user in auth0_users: auth0_user_id = user.get("user_id") if not auth0_user_id: continue auth0_user_id = str(auth0_user_id) try: auth0_organizations_by_user_id[auth0_user_id] = ( get_auth0_organizations_for_user(auth0_user_id, headers, base_url) ) except Exception as exc: # pragma: no cover auth0_organization_errors_by_user_id[auth0_user_id] = str(exc) if neo_error: print(f"{colorize('Neo4j error:', ANSI_RED)} {neo_error}") else: print_neo_data( neo_identities, neo_profiles_by_identity_id, neo_profile_errors_by_identity_id, ) if auth0_error: print(f"{colorize('Auth0 error:', ANSI_RED)} {auth0_error}") else: print_auth0_data( auth0_users, auth0_organizations_by_user_id, auth0_organization_errors_by_user_id, ) if not neo_error and not auth0_error: print_status(neo_identities, auth0_users) finally: close_neo4j_driver() return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(str(exc), file=sys.stderr) raise SystemExit(1)