import enum import json import csv import os from datetime import datetime from pathlib import Path from paramiko import SSHClient from pydantic import BaseModel from app.config import PROD_BEARER_TOKEN from app.dtos import User from app.permisions import get_ssh_client, AVAILABLE_PDP_ROLES, get_pdp_permissions_for_identities from app.feature_flags import check_treatments ALL_ORCHARD_LABELS_UUID = '053a1a75-acc5-4cd8-9206-a194335d2afa' class OldRole(enum.StrEnum): ADMIN = "audience_development_admin" ANALYST = "audience_development_analyst" CLIENT = "audience_development_client" AUDIENCE_MANAGER = "audience_development_audience_manager" class FeatureFlag(enum.StrEnum): ORCHARD_SUITE_SHOW_AUDIENCE_APP = 'orchard_suite_show_audience_app' AUDIENCE_APPLY_ANALYST_ROLE = 'audience_apply_analyst_role' SHOW_SME_DATA = 'show_sme_data' AUDIENCE_ALLOW_AUDIENCE_EXPORT_TO_FILE = 'audience_allow_audience_export_to_file' AUDIENCE_ALLOW_AUDIENCE_SHARING_TO_META = 'audience_allow_audience_sharing_to_meta' AUDIENCE_SHOW_AD_REPORTING = 'audience_show_ad_reporting' AUDIENCE_SHOW_APP_CONNECTIONS = 'audience_show_app_connections' AUDIENCE_SHOW_TIKTOK_AD_ACCOUNTS = 'audience_show_tiktok_ad_accounts' AUDIENCE_SHOW_META_AD_ACCOUNTS = 'audience_show_meta_ad_accounts' AUDIENCE_ENABLE_CUSTOM_LISTS = 'audience_enable_custom_lists' AUDIENCE_SHOW_EMAIL_CAMPAIGNS_PAGE = 'audience_show_email_campaigns_page' AUDIENCE_ALLOW_AUDIENCE_SHARING_TO_ALL_AD_ACCOUNTS = 'audience_allow_audience_sharing_to_all_ad_accounts' AUDIENCE_SHOW_SHOPIFY_STORES_SECTION = 'audience_show_shopify_stores_section' AUDIENCE_ALLOW_SCHEDULE_OR_SEND_EMAIL_CAMPAIGNS = 'audience_allow_schedule_or_send_email_campaigns' class NewRole(enum.StrEnum): FANSIFTER_CAN_VIEW_FAN_DATA = 'fansifter_can_view_fan_data' FANSIFTER_CAN_SHARE_AD_CAMPAIGN_AUDIENCES = 'fansifter_can_share_ad_campaign_audiences' FANSIFTER_CAN_CREATE_AD_REPORTS = 'fansifter_can_create_ad_reports' FANSIFTER_CAN_CONNECT_AD_ACCOUNTS = 'fansifter_can_connect_ad_accounts' FANSIFTER_CAN_VIEW_EMAIL_CAMPAIGNS = 'fansifter_can_view_email_campaigns' FANSIFTER_CAN_CREATE_EMAIL_CAMPAIGNS = 'fansifter_can_create_email_campaigns' # def full_access(self): class OldPermissions(BaseModel): pdp: list present_ff: list absent_ff: list = [] class Transition(BaseModel): old: OldPermissions new: list transition_config = [ Transition( old=OldPermissions( pdp=[OldRole.ADMIN.value], present_ff=[FeatureFlag.ORCHARD_SUITE_SHOW_AUDIENCE_APP.value], ), new=[NewRole.FANSIFTER_CAN_VIEW_FAN_DATA.value], ), Transition( old=OldPermissions( pdp=[OldRole.ANALYST.value], present_ff=[ FeatureFlag.ORCHARD_SUITE_SHOW_AUDIENCE_APP.value, ] ), new=[NewRole.FANSIFTER_CAN_VIEW_FAN_DATA.value], ), Transition( old=OldPermissions( pdp=[OldRole.ADMIN.value], present_ff=[ FeatureFlag.ORCHARD_SUITE_SHOW_AUDIENCE_APP.value, FeatureFlag.AUDIENCE_ALLOW_AUDIENCE_SHARING_TO_META.value ], ), new=[NewRole.FANSIFTER_CAN_SHARE_AD_CAMPAIGN_AUDIENCES.value], ), Transition( old=OldPermissions( pdp=[OldRole.AUDIENCE_MANAGER.value], present_ff=[ FeatureFlag.ORCHARD_SUITE_SHOW_AUDIENCE_APP.value, ], ), new=[NewRole.FANSIFTER_CAN_VIEW_FAN_DATA.value], ), Transition( old=OldPermissions( pdp=[OldRole.AUDIENCE_MANAGER.value], present_ff=[ FeatureFlag.ORCHARD_SUITE_SHOW_AUDIENCE_APP.value, FeatureFlag.AUDIENCE_ALLOW_AUDIENCE_SHARING_TO_META.value ], ), new=[NewRole.FANSIFTER_CAN_SHARE_AD_CAMPAIGN_AUDIENCES.value, NewRole.FANSIFTER_CAN_VIEW_FAN_DATA.value], ), Transition( old=OldPermissions( pdp=[OldRole.ADMIN.value], present_ff=[ FeatureFlag.ORCHARD_SUITE_SHOW_AUDIENCE_APP.value, FeatureFlag.AUDIENCE_SHOW_AD_REPORTING.value ], ), new=[NewRole.FANSIFTER_CAN_CREATE_AD_REPORTS.value], ), Transition( old=OldPermissions( pdp=[OldRole.ANALYST.value], present_ff=[ FeatureFlag.ORCHARD_SUITE_SHOW_AUDIENCE_APP.value, FeatureFlag.AUDIENCE_SHOW_AD_REPORTING.value ], ), new=[NewRole.FANSIFTER_CAN_CREATE_AD_REPORTS.value], ), # Transition( # old=OldPermissions( # pdp=[OldRole.AUDIENCE_MANAGER.value], # present_ff=[ # FeatureFlag.ORCHARD_SUITE_SHOW_AUDIENCE_APP.value, # FeatureFlag.AUDIENCE_SHOW_AD_REPORTING.value # ], # ), # new=[NewRole.FANSIFTER_CAN_CREATE_AD_REPORTS.value], # ), Transition( # create email campaigns old=OldPermissions( pdp=[OldRole.ADMIN.value], present_ff=[ FeatureFlag.AUDIENCE_SHOW_EMAIL_CAMPAIGNS_PAGE.value, ], ), new=[NewRole.FANSIFTER_CAN_CREATE_EMAIL_CAMPAIGNS.value], ), Transition( # app connection old=OldPermissions( pdp=[OldRole.ADMIN.value], present_ff=[ FeatureFlag.AUDIENCE_SHOW_APP_CONNECTIONS.value, ], ), new=[NewRole.FANSIFTER_CAN_CONNECT_AD_ACCOUNTS.value], ), ] def add_permissions(user_uuid, tenant_uuid, roles, ssh_client: SSHClient) -> None: if ssh_client: ssh = ssh_client else: ssh = get_ssh_client() new_roles = [] for role in roles: new_roles.append('{"role": "%(new_role)s"}' % {'new_role': role}) request = """ curl --location --request PUT 'https://prod-ows-pdp.theorchard.io/identity/%(identity_id)s/tenant/%(tenant_uuid)s/attach-and-detach/roles/' \ --header 'Authorization: Bearer %(token)s' \ --header 'Content-Type: application/json' \ --data '{ "tenant_uuid": "%(tenant_uuid)s", "tenant_type": "account", "roles_to_attach": [%(new_roles)s], "roles_to_detach": [] }' """ % { "token": PROD_BEARER_TOKEN, "identity_id": user_uuid, "new_roles": ", ".join(new_roles), "tenant_uuid": tenant_uuid } # print(new_roles) # print(request) stdin, stdout, stderr = ssh.exec_command(request) output = stdout.read().decode('ascii').strip("\n") output = json.loads(output) if output.get("code") == "invalid_token": raise Exception("Invalid token - {}".format(output.get("message"))) def delete_permissions(user_uuid, tenant_uuid, roles, ssh_client) -> None: if ssh_client: ssh = ssh_client else: ssh = get_ssh_client() roles_to_detach = [] for role in roles: roles_to_detach.append('{"role": "%(old_role)s"}' % {'old_role': role}) request = """ curl --location --request PUT 'https://prod-ows-pdp.theorchard.io/identity/%(identity_id)s/tenant/%(tenant_uuid)s/attach-and-detach/roles/' \ --header 'Authorization: Bearer %(token)s' \ --header 'Content-Type: application/json' \ --data '{ "tenant_uuid": "%(tenant_uuid)s", "tenant_type": "account", "roles_to_attach": [], "roles_to_detach": [%(roles_to_detach)s] }' """ % { "token": PROD_BEARER_TOKEN, "identity_id": user_uuid, "roles_to_detach": ", ".join(roles_to_detach), "tenant_uuid": tenant_uuid } # print(roles_to_detach) # print(request) stdin, stdout, stderr = ssh.exec_command(request) output = stdout.read().decode('ascii').strip("\n") output = json.loads(output) if output.get("code") == "invalid_token": raise Exception("Invalid token - {}".format(output.get("message"))) def get_new_roles(old_roles_in_tenant: list, ff) -> set[NewRole]: new_roles = set() for transition in transition_config: old = transition.old if set(old_roles_in_tenant).issuperset(old.pdp) and set(ff).issuperset(set(old.present_ff)) and set(old.absent_ff).isdisjoint(set(ff)): roles_to_add = set(transition.new) - set(old_roles_in_tenant) new_roles.update(roles_to_add) return new_roles def delete_old_roles(users: list[User], users_permissions): ssh = get_ssh_client() for user_id, user_tenants in users_permissions.items(): for tenant_uuid, roles in user_tenants.items(): roles_to_delete = set(roles).intersection(set(AVAILABLE_PDP_ROLES)) if roles_to_delete: delete_permissions(user_id, tenant_uuid, roles_to_delete, ssh) def migrate_to_v2(users: list[User], users_permissions, users_treatments): ssh = get_ssh_client() for user_id, user_tenants in users_permissions.items(): user_ff = users_treatments.get(user_id) if user_ff is None: print(f"Warning: User {user_id} has no feature flags, skipping migration") continue for tenant_uuid, roles in user_tenants.items(): new_roles = get_new_roles(roles, user_ff) if new_roles: add_permissions(user_id, tenant_uuid, new_roles, ssh) def generate_log_filename(): """Generate unique log filename with timestamp and sequence number""" logs_dir = Path("logs") logs_dir.mkdir(exist_ok=True) current_date = datetime.now().strftime("%Y-%m-%d") sequence = 1 while True: filename = f"migration_log_{current_date}_{sequence:03d}.csv" filepath = logs_dir / filename if not filepath.exists(): return str(filepath) sequence += 1 def create_migration_log_entry(user: User, permissions_before: dict, permissions_after: dict, new_roles_added: list, old_roles_removed: list, feature_flags: list, operation_status: str, timestamp: str): """Create a single log entry for migration operation""" # Get vendor names for tenants that have changes vendor_names = [] tenant_uuids = set(permissions_before.keys()) | set(permissions_after.keys()) for tenant_uuid in tenant_uuids: if tenant_uuid == ALL_ORCHARD_LABELS_UUID: vendor_names.append("user with all labels access") else: vendor = next((v for v in user.vendors if v.uuid == tenant_uuid), None) if vendor: vendor_names.append(vendor.name) return { 'email': user.email or '', 'identity': user.identity, 'name': user.name or '', 'vendor_names': ', '.join(vendor_names) if vendor_names else '', 'permissions_before': json.dumps(permissions_before) if permissions_before else '', 'permissions_after': json.dumps(permissions_after) if permissions_after else '', 'new_roles_added': json.dumps(new_roles_added) if new_roles_added else '', 'old_roles_removed': json.dumps(old_roles_removed) if old_roles_removed else '', 'feature_flags': json.dumps(feature_flags) if feature_flags else '', 'operation_status': operation_status, 'timestamp': timestamp } def write_migration_log(log_entries: list, log_file: str): """Write migration log entries to CSV file""" if not log_entries: return fieldnames = ['email', 'identity', 'name', 'vendor_names', 'permissions_before', 'permissions_after', 'new_roles_added', 'old_roles_removed', 'feature_flags', 'operation_status', 'timestamp'] with open(log_file, 'w', newline='', encoding='utf-8') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() writer.writerows(log_entries) def perform_migration(users: list[User], dry_run: bool = False): """ Main migration function that coordinates the entire process Args: users: List of users to migrate dry_run: If True, only show what would be changed without executing Returns: tuple: (success: bool, log_file: str or None) """ print(f"Starting migration for {len(users)} users (dry_run={dry_run})") # Get user identities identities = [user.identity for user in users] try: # Get current permissions and feature flags print("Fetching current permissions...") users_permissions = get_pdp_permissions_for_identities(identities, PROD_BEARER_TOKEN) print("Fetching feature flags...") users_treatments = check_treatments(identities) except Exception as e: print(f"Error fetching data: {e}") return False, None # Prepare log data timestamp = datetime.now().isoformat() log_entries = [] users_to_migrate = {} # user_id -> (new_roles_by_tenant, user_object) users_to_delete_roles = {} # user_id -> (old_roles_by_tenant, user_object) # Create user lookup user_lookup = {user.identity: user for user in users} # Analyze what changes need to be made print("\nAnalyzing required changes...") for user_id, user_tenants in users_permissions.items(): user = user_lookup.get(user_id) if not user: continue user_ff = users_treatments.get(user_id) if user_ff is None: # Log skipped user log_entry = create_migration_log_entry( user, {}, {}, [], [], [], 'skipped_no_feature_flags', timestamp ) log_entries.append(log_entry) print(f"Skipping {user.email} - no feature flags") continue # Calculate new roles for each tenant new_roles_by_tenant = {} permissions_before = user_tenants.copy() permissions_after = user_tenants.copy() all_new_roles = [] for tenant_uuid, roles in user_tenants.items(): new_roles = get_new_roles(roles, user_ff) if new_roles: new_roles_by_tenant[tenant_uuid] = list(new_roles) permissions_after[tenant_uuid] = roles + list(new_roles) all_new_roles.extend(new_roles) # Calculate old roles to remove (independent of new roles) old_roles_by_tenant = {} all_old_roles = [] for tenant_uuid, roles in user_tenants.items(): old_roles_to_remove = list(set(roles).intersection(set(AVAILABLE_PDP_ROLES))) if old_roles_to_remove: old_roles_by_tenant[tenant_uuid] = old_roles_to_remove all_old_roles.extend(old_roles_to_remove) # Update permissions_after to reflect removal permissions_after[tenant_uuid] = [r for r in permissions_after[tenant_uuid] if r not in old_roles_to_remove] # Process user if they have new roles to add OR old roles to remove if new_roles_by_tenant or old_roles_by_tenant: if new_roles_by_tenant: users_to_migrate[user_id] = (new_roles_by_tenant, user) if old_roles_by_tenant: users_to_delete_roles[user_id] = (old_roles_by_tenant, user) # Log successful migration log_entry = create_migration_log_entry( user, permissions_before, permissions_after, list(set(all_new_roles)), list(set(all_old_roles)), user_ff, 'success', timestamp ) log_entries.append(log_entry) print(f"User {user.email}:") if dry_run: # Check for problematic roles (users with only new roles but missing view_fan_data) problematic_tenants = [] new_role_values = [role.value for role in NewRole] # Check all tenants even if no migration needed all_user_tenants = user_tenants if user_tenants else {} for tenant_uuid, current_roles in all_user_tenants.items(): if current_roles: # Check if all current roles are new roles (from NewRole enum) all_roles_are_new = all(role in new_role_values for role in current_roles) has_view_fan_data = NewRole.FANSIFTER_CAN_VIEW_FAN_DATA.value in current_roles # If user has only new roles but missing view_fan_data - mark as problematic if all_roles_are_new and not has_view_fan_data and current_roles: problematic_tenants.append(tenant_uuid) # Show detailed changes per tenant in dry-run mode for tenant_uuid, roles in user_tenants.items(): tenant_new_roles = new_roles_by_tenant.get(tenant_uuid, []) tenant_old_roles = old_roles_by_tenant.get(tenant_uuid, []) if tenant_new_roles or tenant_old_roles: # Find vendor name for this tenant if tenant_uuid == ALL_ORCHARD_LABELS_UUID: vendor_name = " (user with all labels access)" else: vendor = next((v for v in user.vendors if v.uuid == tenant_uuid), None) vendor_name = f" ({vendor.name})" if vendor else "" # Highlight problematic tenants in red if tenant_uuid in problematic_tenants: print(f" [red]⚠️ Tenant {tenant_uuid}{vendor_name}[/red]:") print(f" [red]WARNING: User will have new roles but missing 'view_fan_data' role![/red]") else: print(f" Tenant {tenant_uuid}{vendor_name}:") print(f" Current roles: {roles}") if tenant_new_roles: print(f" → New roles to add: {tenant_new_roles}") if tenant_old_roles: print(f" → Old roles to remove: {tenant_old_roles}") final_roles = [r for r in (roles + tenant_new_roles) if r not in tenant_old_roles] print(f" → Final roles: {final_roles}") print() else: print(f" New roles to add: {list(set(all_new_roles))}") print(f" Old roles to remove: {list(set(all_old_roles))}") else: # Even if no migration needed, check for problematic roles in dry-run mode if dry_run: problematic_tenants = [] new_role_values = [role.value for role in NewRole] for tenant_uuid, current_roles in user_tenants.items(): if current_roles: # Check if all current roles are new roles (from NewRole enum) all_roles_are_new = all(role in new_role_values for role in current_roles) has_view_fan_data = NewRole.FANSIFTER_CAN_VIEW_FAN_DATA.value in current_roles # If user has only new roles but missing view_fan_data - mark as problematic if all_roles_are_new and not has_view_fan_data and current_roles: problematic_tenants.append(tenant_uuid) if problematic_tenants: print(f"User {user.email}:") for tenant_uuid in problematic_tenants: # Find vendor name for this tenant if tenant_uuid == ALL_ORCHARD_LABELS_UUID: vendor_name = " (user with all labels access)" else: vendor = next((v for v in user.vendors if v.uuid == tenant_uuid), None) vendor_name = f" ({vendor.name})" if vendor else "" current_roles = user_tenants.get(tenant_uuid, []) print(f" [red]⚠️ Tenant {tenant_uuid}{vendor_name}[/red]:") print(f" [red]WARNING: User has new roles but missing 'view_fan_data' role![/red]") print(f" Current roles: {current_roles}") print() else: print(f"No changes needed for {user.email}") else: print(f"No changes needed for {user.email}") # Show summary print(f"\nMigration summary:") print(f" Users to migrate: {len(users_to_migrate)}") print(f" Users to clean old roles: {len(users_to_delete_roles)}") print(f" Users skipped: {len(users) - len(users_to_migrate)}") if dry_run: print("\nDry run completed - no changes made") return True, None # Execute migration if not users_to_migrate and not users_to_delete_roles: print("No users to migrate") return True, None print("\nExecuting migration...") # Step 1: Add new roles (if any) if users_to_migrate: try: ssh = get_ssh_client() for user_id, (new_roles_by_tenant, user) in users_to_migrate.items(): print(f"Adding new roles for {user.email}...") for tenant_uuid, new_roles in new_roles_by_tenant.items(): add_permissions(user_id, tenant_uuid, new_roles, ssh) except Exception as e: print(f"Error during role addition: {e}") return False, None print("New roles added successfully!") else: print("No new roles to add - proceeding to role removal...") # Step 2: Show current state and ask for confirmation to delete old roles if users_to_delete_roles: print(f"\nReady to remove old roles for {len(users_to_delete_roles)} users.") # Step 3: Remove old roles try: if not users_to_migrate: # SSH client not initialized yet ssh = get_ssh_client() for user_id, (old_roles_by_tenant, user) in users_to_delete_roles.items(): print(f"Removing old roles for {user.email}...") for tenant_uuid, old_roles in old_roles_by_tenant.items(): delete_permissions(user_id, tenant_uuid, old_roles, ssh) except Exception as e: print(f"Error during role removal: {e}") return False, None print("Old roles removed successfully!") else: print("No old roles to remove.") # Write log log_file = generate_log_filename() write_migration_log(log_entries, log_file) print(f"Migration log saved to: {log_file}") return True, log_file