import re import argparse import logging import sys import click from neo4j import GraphDatabase, RoutingControl from app.dtos import User, Vendor, VendorStatus from app.migrate_to_v2_permissions import migrate_to_v2, perform_migration, NewRole from app.migrate_email_campaigns import perform_email_campaigns_migration from app.tables import build_users_data_table, build_permissions_transition_table, show_pre_saved_vendors from app.feature_flags import check_treatments from app.config import ( NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD, QA_NEO4J_URI, QA_NEO4J_PASSWORD, QA_NEO4J_USERNAME, PROD_BEARER_TOKEN, QA, PROD, DEFAULT_FEATURE_FLAGS, ALL_ORCHARD_LABELS_UUID ) from rich.console import Console from app.database import check_creds_for_database_pr, prepare_database_pr from app.permisions import get_pdp_permissions_for_identities, update_permissions, delete_permissions, \ check_resource_type_actions, check_resource_test from app.utils import check_vpn_is_working, alert_message_and_exit logging.basicConfig(level=logging.CRITICAL) DEFAULT_FILE_NAME = "users.csv" TICKET_PATTERN = r'^FS-\d{4}_([\w_]+)$' def validate_users_has_vendor_access_and_role(users: list[User], vendors_uuids: list[str], check_role: bool = True) -> bool: for vendor_uuid in vendors_uuids: for user in users: has_vendor_access = False # Check if user has ALL_ORCHARD_LABELS_UUID (universal access) if ALL_ORCHARD_LABELS_UUID in [vendor.uuid for vendor in user.vendors]: has_vendor_access = True else: # Check if user has direct access to this vendor for vendor in user.vendors: if vendor.uuid == vendor_uuid: has_vendor_access = True break if not user.audience_profile or not has_vendor_access: alert_message_and_exit(f"User {user.email or user.identity} has no access to vendor {vendor_uuid}.") if check_role and not user.role: alert_message_and_exit(f"User {user.email} missed role") def validate_jira_ticket_name(string): match = re.match(TICKET_PATTERN, string) if match: ticket_name = match.group(1) else: raise ValueError(f"{string} is not a valid ticket name, check example in script usage") def get_users_by_emails(emails: list[str], uri, auth) -> list: with GraphDatabase.driver(uri=uri, auth=auth) as driver: driver.verify_connectivity() emails = ', '.join(['"' + email + '"' for email in emails]) records, summary, keys = driver.execute_query( """MATCH (n:Identity) WHERE ANY(email IN n.email WHERE email IN [ %s ]) OPTIONAL MATCH (n)-[:HAS_PROFILE]->(p:Profile {profileType: "AudienceProfile"})-[:HAS_ACCESS_TO]->(v:Vendor) RETURN DISTINCT n.id AS user_id, n.email AS email, n.name AS name, p as profile, COLLECT(v) AS vendors""" % (emails), routing_=RoutingControl.READ, ) return records def get_vendors(vendors: list, uri, auth) -> list: """ :param vendors: could be vendor ID or UUID """ with GraphDatabase.driver(uri=uri, auth=auth) as driver: driver.verify_connectivity() ids: list[int] = [] uuids: list[str] = [] for vendor in vendors: if type(vendor) is int: ids.append(vendor) elif vendor.count('-') == 4: uuids.append(vendor) else: alert_message_and_exit(f"{vendor} is not a valid vendor ID nor UUID, check example in script usage") records, summary, keys = driver.execute_query( f"""MATCH (v:Vendor) WHERE ANY (id IN v.id WHERE id IN {ids}) OR ANY (uuid IN v.uuid WHERE uuid IN {uuids}) RETURN v.id AS id, v.uuid AS uuid, v.name AS name """, routing_=RoutingControl.READ, ) return records def get_users_by_identities(identities: list[str], uri, auth) -> list: with GraphDatabase.driver(uri=uri, auth=auth) as driver: driver.verify_connectivity() identities = ', '.join(['"' + identity + '"' for identity in identities]) records, summary, keys = driver.execute_query( """MATCH (n:Identity) WHERE ANY(id IN n.id WHERE id IN [ %s ]) OPTIONAL MATCH (n)-[:HAS_PROFILE]->(p:Profile {profileType: "AudienceProfile"})-[:HAS_ACCESS_TO]->(v:Vendor) RETURN DISTINCT n.id AS user_id, n.email AS email, n.name AS name, p as profile, COLLECT(v) AS vendors""" % ( identities), routing_=RoutingControl.READ, ) return records def fill_users_data_from_neo4j(users: list[User], neo4j_users: list, match_by:str = "email"): for neo4j_user in neo4j_users: for user in users: if match_by == "id" and user.identity == neo4j_user.get("user_id"): user.update_from_neo4j_model(neo4j_user) break elif user.email == neo4j_user.get("email"): user.update_from_neo4j_model(neo4j_user) break def collect_all_pdp_vendor_uuids(users_permissions: dict) -> set[str]: """ Collect all unique vendor UUIDs from PDP permissions across all users """ all_vendor_uuids = set() for user_permissions in users_permissions.values(): all_vendor_uuids.update(user_permissions.keys()) # Remove ALL_ORCHARD_LABELS_UUID from the set as it's not a real vendor all_vendor_uuids.discard(ALL_ORCHARD_LABELS_UUID) return all_vendor_uuids def expand_users_missing_vendors(users: list[User], users_permissions: dict, pdp_vendors_data: list): """ For all users, expand their vendor list to include vendors from PDP permissions that are missing from Neo4j vendors. Mark as EMULATED for FTE users (orange) or MISSING for regular users (red). """ # Create a mapping of UUID to vendor data vendor_uuid_to_data = {vendor.get("uuid"): vendor for vendor in pdp_vendors_data} # Process all users for user in users: if not user.exists or user.identity not in users_permissions: continue # Check if user has ALL_ORCHARD_LABELS_UUID has_all_orchard_access = user.vendors and any(vendor.uuid == ALL_ORCHARD_LABELS_UUID for vendor in user.vendors) user_pdp_permissions = users_permissions[user.identity] # Get tenant UUIDs for this user (excluding ALL_ORCHARD_LABELS_UUID) user_tenant_uuids = [tenant_uuid for tenant_uuid in user_pdp_permissions.keys() if tenant_uuid != ALL_ORCHARD_LABELS_UUID] # Create a set of existing vendor UUIDs to avoid duplicates existing_vendor_uuids = {vendor.uuid for vendor in user.vendors} if user.vendors else set() # Add missing vendors from PDP for tenant_uuid in user_tenant_uuids: if tenant_uuid not in existing_vendor_uuids and tenant_uuid in vendor_uuid_to_data: vendor_data = vendor_uuid_to_data[tenant_uuid] if has_all_orchard_access: # FTE user - mark as EMULATED (orange) missing_vendor = Vendor( uuid=tenant_uuid, name=f"{vendor_data.get('name')} *", id=vendor_data.get("id"), status=VendorStatus.EMULATED ) else: # Regular user - mark as MISSING (red) missing_vendor = Vendor( uuid=tenant_uuid, name=f"⚠ {vendor_data.get('name')} (PDP only)", id=vendor_data.get("id"), status=VendorStatus.MISSING ) if user.vendors is None: user.vendors = [] user.vendors.append(missing_vendor) def grant_fte_permissions(users: list[User], users_permissions: dict, vendors_uuids: list[str]): """ Grant full set of permissions v2 to users with ALL_ORCHARD_LABELS_UUID for specified vendors Only adds missing permissions, skips vendors where user already has all FTE permissions """ from app.migrate_to_v2_permissions import add_permissions, get_ssh_client # Full time employee permissions using enum fte_permissions = [ NewRole.FANSIFTER_CAN_VIEW_FAN_DATA.value, NewRole.FANSIFTER_CAN_SHARE_AD_CAMPAIGN_AUDIENCES.value, NewRole.FANSIFTER_CAN_CREATE_AD_REPORTS.value, NewRole.FANSIFTER_CAN_CONNECT_AD_ACCOUNTS.value, NewRole.FANSIFTER_CAN_CREATE_EMAIL_CAMPAIGNS.value ] ssh_client = get_ssh_client() for user in users: if not user.exists or not user.vendors: continue # Check if user has ALL_ORCHARD_LABELS_UUID has_all_orchard_access = any(vendor.uuid == ALL_ORCHARD_LABELS_UUID for vendor in user.vendors) if has_all_orchard_access and user.identity in users_permissions: user_pdp_permissions = users_permissions[user.identity] # Grant FTE permissions only to specified vendors for vendor_uuid in vendors_uuids: # Check current permissions for this vendor current_permissions = user_pdp_permissions.get(vendor_uuid, []) # Calculate missing permissions missing_permissions = [perm for perm in fte_permissions if perm not in current_permissions] if not missing_permissions: print(f"✅ {user.email} already has all FTE permissions for vendor {vendor_uuid}, skipping") continue print(f"Granting missing FTE permissions to {user.email} for vendor {vendor_uuid}: {missing_permissions}") try: add_permissions(user.identity, vendor_uuid, missing_permissions, ssh_client) print(f"✅ Successfully granted FTE permissions to {user.email} for vendor {vendor_uuid}") except Exception as e: print(f"❌ Failed to grant FTE permissions to {user.email} for vendor {vendor_uuid}: {e}") ssh_client.close() def main(): neo4j_uri = NEO4J_URI neo4j_auth = (NEO4J_USERNAME, NEO4J_PASSWORD) parser = argparse.ArgumentParser(description="Fansіfter users create/update helper and full info about them") parser.add_argument("--environment", default=PROD, choices=[QA, PROD], type=str, help="Neo4J environment name (e.g. qa, prod) the corresponding constants in the env file must be specified") users_input_group = parser.add_mutually_exclusive_group(required=True) users_input_group.add_argument( "-e", "--emails", metavar="EMAIL1,EMAIL2,...", type=str, help="Comma-separated list of email addresses" ) users_input_group.add_argument( "-i", "--identities", metavar="IDENTITY1,IDENTITY2,...", type=str, help="Comma-separated list of identities" ) users_input_group.add_argument( "-f", "--csv-file", metavar="FILE", type=str, help="Path to CSV file containing users data without headers" ) users_input_group.add_argument( "--test", action='store_true', help="For test purpose" ) parser.add_argument("-v", "--vendors", metavar="VENDOR1_ID,VENDOR2_UUID,...", type=str, help="Comma-separated list of Vendors IDs/UUIDs (to check pre-saved vendors list use flag --saved-vendors)") action_input_group = parser.add_mutually_exclusive_group() action_input_group.add_argument( "-d", "--database-pr", type=str, help="Create database PR with name for e.g. FS-0000_jira_ticket_name. Works only with -f and -v flags" ) action_input_group.add_argument( "-p", "--permissions", action='store_true', help="Update permissions for given users to given role. Works with -f and -v flags." ) action_input_group.add_argument( '--ff', type=str, help="Help with feature flags updates." ) parser.add_argument("-m", "--migrate", action='store_true', help="Migrate users permissions to V2") parser.add_argument("--md", "--migrate-dry-run", action='store_true', dest='migrate_dry_run', help="Show migration plan without executing changes") parser.add_argument("--mec", "--migrate-email-campaigns", action='store_true', dest='migrate_email_campaigns', help="Migrate users from fansifter_can_view_email_campaigns to fansifter_can_create_email_campaigns role") parser.add_argument("--saved-vendors", action='store_true') parser.add_argument("--ids", action='store_true') parser.add_argument("--delete", action='store_true', help="Delete users pdp roles in vendor") parser.add_argument("--fte", action='store_true', help="Grant full set of permissions v2 to users with ALL_ORCHARD_LABELS_UUID (full time employee)") console = Console() args = parser.parse_args() if args.test: check_resource_test() sys.exit(0) if args.saved_vendors: show_pre_saved_vendors() if not check_vpn_is_working(): alert_message_and_exit('Please turn on VPN') if args.database_pr and args.environment == QA: alert_message_and_exit('Automatic database PR generation available only with prod access please remove -e flag') if args.environment == QA: console.print('[yellow]Pay attention this is data from QA env.\nUse QA env only for checking qa deployment result.[/yellow]') neo4j_uri = QA_NEO4J_URI neo4j_auth = (QA_NEO4J_USERNAME, QA_NEO4J_PASSWORD) if args.database_pr and (not args.csv_file or not args.vendors): alert_message_and_exit('You should specify file (-f) and vendors (-v) to be able to create database migration') vendors_ids = [] vendors_uuids = [] users: list[User] = [] vendors_data = [] if args.vendors: user_entered_vendors = args.vendors.split(',') user_entered_vendors = [int(vendor) if vendor.isnumeric() else vendor for vendor in user_entered_vendors] vendors_data = get_vendors(user_entered_vendors, neo4j_uri, neo4j_auth) existed_vendors_ids = [(vendor.get("id"), vendor.get("uuid")) for vendor in vendors_data] existed_vendors_ids = [item for tup in existed_vendors_ids for item in tup] not_existed_vendors = [] for user_vendor in user_entered_vendors: if user_vendor not in existed_vendors_ids: not_existed_vendors.append(user_vendor) if not_existed_vendors: alert_message_and_exit(f"Vendors with ids: {not_existed_vendors} not exists, please remove them or update value.") for vendor in vendors_data: vendors_ids.append(vendor.get("id")) vendors_uuids.append(vendor.get("uuid")) console.print("[green]Script runs for Vendors:[/green]") for vendor in vendors_data: console.print(f"{vendor.get('name')} - {vendor.get('id')} - {vendor.get('uuid')}") if args.emails: emails = args.emails.split(',') users = [User(email=email.lower()) for email in emails] neo4j_users = get_users_by_emails(emails, neo4j_uri, neo4j_auth) fill_users_data_from_neo4j(users, neo4j_users) elif args.identities: identities = args.identities.split(',') users = [User(identity=identity) for identity in identities] neo4j_users = get_users_by_identities(identities, neo4j_uri, neo4j_auth) fill_users_data_from_neo4j(users, neo4j_users, match_by="id") elif args.csv_file: emails = [] with open(args.csv_file) as csv_file: file_content = csv_file.readlines() for row in file_content: row = row.strip().split(',') emails.append(row[1].lower().strip()) users.append( User(name=row[0].strip(), email=row[1].lower().strip(), role=row[3].lower().strip()) ) neo4j_users = get_users_by_emails(emails, neo4j_uri, neo4j_auth) fill_users_data_from_neo4j(users, neo4j_users) if args.database_pr: validate_jira_ticket_name(args.database_pr) check_creds_for_database_pr() prepare_database_pr(args.database_pr, vendors_ids=vendors_ids, users_data=users) existed_users = [user.identity for user in users if user.exists] not_existed_users = [user.identity for user in users if not user.exists] users_treatments = check_treatments(existed_users) users_permissions = {} token_expired = False try: users_permissions = get_pdp_permissions_for_identities(existed_users, PROD_BEARER_TOKEN) except: alert_message_and_exit(f"Token for getting permissions expired.", exit_=False) token_expired = True # Process users with ALL_ORCHARD_LABELS_UUID to expand their vendor access if users_permissions: # First, collect all unique vendor IDs from PDP permissions all_pdp_vendor_uuids = collect_all_pdp_vendor_uuids(users_permissions) # Get vendor data for all PDP vendors pdp_vendors_data = get_vendors(list(all_pdp_vendor_uuids), neo4j_uri, neo4j_auth) if all_pdp_vendor_uuids else [] # Expand vendors for all users (check for missing PDP vendors) expand_users_missing_vendors(users, users_permissions, pdp_vendors_data) build_users_data_table( users, users_permissions=users_permissions, feature_flags_treatments=users_treatments, highlights={"vendors": vendors_uuids} ) if args.migrate or args.migrate_dry_run: if not_existed_users: alert_message_and_exit("We could migrate only users that exists") dry_run = bool(args.migrate_dry_run) console.print(f"[green]PDP Roles migration to V2 {'(DRY RUN)' if dry_run else ''} started:[/green]") success, log_file = perform_migration(users, dry_run=dry_run) if success: if log_file: console.print(f"[green]Migration completed successfully! Log saved to: {log_file}[/green]") else: console.print("[green]Migration analysis completed successfully![/green]") else: alert_message_and_exit("Migration failed. Check error messages above.") if args.migrate_email_campaigns: if not_existed_users: alert_message_and_exit("We could migrate only users that exists") console.print("[green]Email Campaigns role migration started:[/green]") success = perform_email_campaigns_migration(users) if success: console.print("[green]Email Campaigns migration completed successfully![/green]") else: alert_message_and_exit("Email Campaigns migration failed. Check error messages above.") if args.permissions: if not args.csv_file: alert_message_and_exit(f"For now permissions updates available only with -f flag") if not_existed_users: alert_message_and_exit(f"Permissions updates works only for existed user with access to vendor in ") if token_expired: alert_message_and_exit(f"We can't update permissions because of token expire. Update token and rerun script.") if len(vendors_uuids) == 0: alert_message_and_exit("Vendor data is missing for permissions updates.") vendors_uuids = [vendor.get("uuid") for vendor in vendors_data] validate_users_has_vendor_access_and_role(users, vendors_uuids) for vendor in vendors_data: build_permissions_transition_table(users, users_permissions, vendor.get("name"), vendor.get("uuid")) if click.confirm('Do you want to update permissions like this show on transition table?', default=True): try: update_permissions(users, vendors_uuids) users_permissions = get_pdp_permissions_for_identities(existed_users, PROD_BEARER_TOKEN) build_users_data_table( users, users_permissions=users_permissions, feature_flags_treatments=users_treatments, highlights={"vendors": vendors_uuids} ) except Exception as e: alert_message_and_exit(f"Token for getting permissions expired or exception occurs - {e}. Update token and run script again.") if args.ids: console.print("[green]Users ids:[/green]") for user in users: console.print(f"[yellow]{user.identity}[/yellow]") if args.delete: if click.confirm('Do you want to delete permissions for current user from this vendors?', default=True): delete_permissions(users, vendors_uuids) if args.ff: feature_flags = args.ff.split(',') if not set(feature_flags).issubset(DEFAULT_FEATURE_FLAGS): alert_message_and_exit(f"{args.ff} not in default feature flag list {DEFAULT_FEATURE_FLAGS}. Fix or extend list.") update_feature_flags_result = {f: [] for f in feature_flags} for user_id in users_treatments: user_ff = users_treatments.get(user_id) ff_to_add = set(feature_flags).difference(set(user_ff)) for ff in ff_to_add: update_feature_flags_result[ff].append(user_id) console.print("[green]Users add to feature flags>[/green]") for f in update_feature_flags_result: console.print(f"[green]{f}[/green]: [yellow]{','.join(update_feature_flags_result.get(f))}[/yellow]") if args.fte: if not_existed_users: alert_message_and_exit("We can grant FTE permissions only to users that exist") if token_expired: alert_message_and_exit("We can't grant FTE permissions because of token expire. Update token and rerun script.") if len(vendors_uuids) == 0: alert_message_and_exit("Vendor data is missing for FTE permissions. Use -v flag to specify vendors.") vendors_uuids = [vendor.get("uuid") for vendor in vendors_data] validate_users_has_vendor_access_and_role(users, vendors_uuids, check_role=False) console.print("[green]Granting FTE permissions to users with ALL_ORCHARD_LABELS_UUID:[/green]") # Filter users that have ALL_ORCHARD_LABELS_UUID fte_users = [user for user in users if user.exists and user.vendors and any(vendor.uuid == ALL_ORCHARD_LABELS_UUID for vendor in user.vendors)] if not fte_users: console.print("[yellow]No users found with ALL_ORCHARD_LABELS_UUID access.[/yellow]") else: console.print(f"[green]Found {len(fte_users)} users with ALL_ORCHARD_LABELS_UUID access:[/green]") for user in fte_users: console.print(f" - {user.email}") console.print(f"[green]Will grant FTE permissions for vendors:[/green]") for vendor in vendors_data: console.print(f" - {vendor.get('name')} ({vendor.get('uuid')})") if click.confirm('Do you want to grant FTE permissions to these users for specified vendors?', default=True): try: grant_fte_permissions(fte_users, users_permissions, vendors_uuids) console.print("[green]FTE permissions granted successfully![/green]") # Refresh permissions and show updated table users_permissions = get_pdp_permissions_for_identities(existed_users, PROD_BEARER_TOKEN) expand_users_missing_vendors(users, users_permissions, pdp_vendors_data) build_users_data_table( users, users_permissions=users_permissions, feature_flags_treatments=users_treatments, highlights={"vendors": vendors_uuids} ) except Exception as e: alert_message_and_exit(f"Failed to grant FTE permissions: {e}") if __name__ == '__main__': main()