#!/usr/bin/env python3 """ Fuzzy match unmatched RADIUS users against Gaiia accounts. Smarter matching: requires first-name correlation when matching on last name, handles substring traps ("kim" in "kimberly"), etc. """ import csv import json import re import subprocess import sys from difflib import SequenceMatcher GAIIA_KEY = "gaiia_sk_live_955b27e3680747a69ad40b403a912155_b0d1be3c" GAIIA_ENDPOINT = "https://api.gaiia.com/api/v1" def fetch_all_accounts(): accounts = [] cursor = None while True: after_arg = f', after: "{cursor}"' if cursor else "" query = f""" query {{ accounts(first: 250{after_arg}) {{ nodes {{ id readableId name displayName }} pageInfo {{ hasNextPage endCursor }} }} }} """ result = subprocess.run( ["curl", "-s", "-X", "POST", GAIIA_ENDPOINT, "-H", "Content-Type: application/json", "-H", f"X-Gaiia-Api-Key: {GAIIA_KEY}", "-H", "x-timezone: America/Chicago", "-d", json.dumps({"query": query})], capture_output=True, text=True, timeout=30, ) data = json.loads(result.stdout) for n in data["data"]["accounts"]["nodes"]: name = n.get("displayName") or n.get("name") or "" if name: accounts.append((n["id"], name.strip(), n["readableId"])) page_info = data["data"]["accounts"]["pageInfo"] if not page_info["hasNextPage"]: break cursor = page_info["endCursor"] return accounts def normalize(s): return re.sub(r'[^\w\s]', '', s.lower()).strip() def split_name(display_name): """Split into first name(s) and last name.""" parts = normalize(display_name).split() if len(parts) == 1: return parts[0], "" # Filter out suffixes, LLC, numbers, etc. content_parts = [p for p in parts if not re.match(r'^(#?\d+|llc|inc|ltd|shop|jr|sr)$', p)] if len(content_parts) >= 2: return " ".join(content_parts[:-1]), content_parts[-1] return content_parts[0], "" def first_name_match_score(username, last_name, first_name_str): """How well does the first name correlate with the username? Returns 0-1. The first initial must appear as the FIRST character of the username portion before the last name (natural "firstname+lastname" order). This prevents 'a' from "Amy" spuriously matching inside "ryan". """ if not first_name_str: return 0.5 # neutral # Find where last name appears in username last_idx = username.find(last_name) if last_name else -1 username_before_last = username[:last_idx] if last_idx > 0 else username first_names = first_name_str.split() best = 0.0 for fn in first_names: if len(fn) < 2: continue fn_init = fn[0] if fn in username: return 1.0 # full first name in username # First initial must be the FIRST character before the last name if username_before_last and username_before_last[0] == fn_init: best = max(best, 0.7) # Fuzzy match ratio = SequenceMatcher(None, fn, username).ratio() best = max(best, ratio * 0.8) return best def match_unmatched(unmatched_users, accounts): """Fuzzy match with smarter scoring.""" # Manual overrides from user knowledge MANUAL_MATCHES = { # (username, readable_id) -> forces match to specific Gaiia account "barbihardin": "James Hardin", "daycor": ("Cathy Day", 755), "tech": ("Graham McIntire", 1), # Add more as discovered } # Build lookup by name accounts_by_name = {normalize(name): (aid, name, rid) for aid, name, rid in accounts} results = [] for user in unmatched_users: username = user["username"].lower() username_no_digits = re.sub(r'\d+$', '', username) # Check manual overrides first if username in MANUAL_MATCHES: override = MANUAL_MATCHES[username] # tuple form: (display_name, readable_id) if isinstance(override, tuple): target_name, target_rid = override for aid, name, rid in accounts: if normalize(name) == normalize(target_name) and rid == target_rid: results.append({ **user, "gaiia_id": aid, "gaiia_name": name, "gaiia_readable_id": rid, "match_type": "fuzzy(manual_override)", }) break else: # Fallback: match by name only norm_target = normalize(target_name) if norm_target in accounts_by_name: aid, name, rid = accounts_by_name[norm_target] results.append({ **user, "gaiia_id": aid, "gaiia_name": name, "gaiia_readable_id": rid, "match_type": "fuzzy(manual_override)", }) continue continue # String form: match by name target_name = override norm_target = normalize(target_name) if norm_target in accounts_by_name: aid, name, rid = accounts_by_name[norm_target] results.append({ **user, "gaiia_id": aid, "gaiia_name": name, "gaiia_readable_id": rid, "match_type": "fuzzy(manual_override)", }) continue best_match = None best_score = 0.0 best_reason = "" for acct_id, acct_name, readable_id in accounts: first_names, last_name = split_name(acct_name) aname = normalize(acct_name) aname_parts = aname.split() score = 0.0 reason = "" # ---- Strategy 1: last name match + first name correlation ---- if last_name and len(last_name) >= 3: # Check that last_name appears in username as a distinct substring # (not "kim" inside "kimberly") idx = username.find(last_name) if idx >= 0: # Check it's a reasonable boundary match # Allow: at start, at end, or surrounded by non-alpha before_ok = idx == 0 or not username[idx-1].isalpha() after_ok = (idx + len(last_name) == len(username)) or not username[idx+len(last_name)].isalpha() if not before_ok and not after_ok: # "kim" inside "kimberly" - reject this last name match pass else: # Check first name correlation fn_score = first_name_match_score(username, last_name, first_names) if fn_score >= 0.5: score = 0.7 + (fn_score * 0.2) reason = f"last+first: {last_name}+{first_names}(fn={fn_score:.2f})" elif fn_score > 0: score = 0.6 + fn_score * 0.1 reason = f"last+weakfirst: {last_name}+{first_names}(fn={fn_score:.2f})" else: # Last name match but no first name correlation - low confidence score = 0.55 reason = f"lastonly_nofirst: {last_name}" # ---- Strategy 2: Sequence matcher on nodigits variant ---- if score < 0.6 and username_no_digits != username: seq_score = SequenceMatcher(None, username_no_digits, aname).ratio() if seq_score > 0.75: score = max(score, seq_score * 0.9) reason = f"seq_nodigits: {seq_score:.2f}" # ---- Strategy 3: Direct sequence match ---- if score < 0.6: seq_score = SequenceMatcher(None, username, aname).ratio() if seq_score > 0.75: score = max(score, seq_score * 0.9) reason = f"sequence: {seq_score:.2f}" # ---- Strategy 4: All account name parts in username ---- if score < 0.6: all_found = True for part in aname_parts: if len(part) >= 3 and part not in username: all_found = False break if all_found and len(aname_parts) >= 2: score = 0.85 reason = f"allparts: {aname}" if score > best_score: best_score = score best_match = (acct_id, acct_name, readable_id, score) best_reason = reason # Threshold: 0.7+ if best_match and best_score >= 0.7: results.append({ **user, "gaiia_id": best_match[0], "gaiia_name": best_match[1], "gaiia_readable_id": best_match[2], "match_type": f"fuzzy({best_reason})", }) else: results.append({ **user, "gaiia_id": user.get("gaiia_id", ""), "gaiia_name": user.get("gaiia_name", ""), "gaiia_readable_id": user.get("gaiia_readable_id", ""), "match_type": f"still_unmatched(best: {best_reason}, score: {round(best_score,3)})", }) return results def resolve_ambiguous(ambiguous_rows, accounts): """For ambiguous matches (duplicate Gaiia names), pick the lowest readable_id.""" results = [] for row in ambiguous_rows: ambig_names = row["gaiia_name"].replace("AMBIGUOUS: ", "").split(", ") candidates = [(a_id, name, r_id) for a_id, name, r_id in accounts if name in ambig_names] if candidates: candidates.sort(key=lambda x: x[2]) row["gaiia_id"] = candidates[0][0] row["gaiia_name"] = candidates[0][1] row["gaiia_readable_id"] = candidates[0][2] row["match_type"] = "ambiguous_resolved(lowest_id)" else: row["match_type"] = "ambiguous_unresolved" results.append(row) return results def main(): reader = csv.DictReader(sys.stdin) all_rows = list(reader) unmatched = [r for r in all_rows if r["match_type"] == "no_match"] ambiguous = [r for r in all_rows if r["match_type"] == "ambiguous"] print(f"Unmatched: {len(unmatched)}, Ambiguous: {len(ambiguous)}", file=sys.stderr) print("Fetching Gaiia accounts...", file=sys.stderr) accounts = fetch_all_accounts() print(f"Fetched {len(accounts)} accounts", file=sys.stderr) matched_results = match_unmatched(unmatched, accounts) ambig_results = resolve_ambiguous(ambiguous, accounts) all_results = matched_results + ambig_results fieldnames = ["radius_id", "username", "password", "gaiia_id", "gaiia_name", "gaiia_readable_id", "match_type"] writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames) writer.writeheader() for r in all_results: writer.writerow({k: r.get(k, "") for k in fieldnames}) newly_matched = sum(1 for r in matched_results if r["gaiia_id"]) still = sum(1 for r in matched_results if "still_unmatched" in r.get("match_type", "")) print(f"\nNewly matched: {newly_matched}, Still unmatched: {still}", file=sys.stderr) print("\nStill unmatched:", file=sys.stderr) for r in matched_results: if "still_unmatched" in r.get("match_type", ""): print(f" {r['username']:30s} ({r['match_type']})", file=sys.stderr) if __name__ == "__main__": main()