#!/usr/bin/env python3 """ Bulk Transfer Dashboard/Workbook Ownership via Sigma API This script allows bulk transfer of ownership for dashboards/workbooks from one user to another in Sigma Computing. Usage: python transfer_ownership.py --from user@example.com --to newowner@example.com python transfer_ownership.py --from user@example.com --to newowner@example.com --type folder python transfer_ownership.py --from user@example.com --to newowner@example.com --dry-run python transfer_ownership.py --from user@example.com --to newowner@example.com --yes """ import argparse import json import logging import os import sys import time from datetime import datetime from typing import List, Dict, Any, Optional, Callable from dataclasses import dataclass from requests.exceptions import HTTPError from sigma_api_client import SigmaAPIClient from config import ConfigManager def setup_logging(log_file: Optional[str] = None) -> Optional[str]: """ Configure logging for audit trail Args: log_file: Optional log file path. If None, generates timestamped filename. Returns: Path to log file if logging is enabled, None otherwise """ if log_file is None: return None # Auto-generate filename if directory provided if os.path.isdir(log_file): log_file = os.path.join( log_file, f"transfer_ownership_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" ) logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(log_file), logging.StreamHandler(sys.stderr) ] ) return log_file @dataclass class TransferResult: """Result of a single ownership transfer""" inode_id: str name: str success: bool file_type: Optional[str] = None error: Optional[str] = None class OwnershipManager: """ Manages file ownership operations in Sigma Computing """ # File types that represent dashboards/workbooks DASHBOARD_TYPES = ['workbook'] ALL_TYPES = ['workbook', 'folder'] def __init__(self, api_client: SigmaAPIClient): self.api = api_client def list_all_members(self, limit: int = 500) -> List[Dict[str, Any]]: """ List all members with pagination support Args: limit: Maximum number of members per page Returns: List of all member dictionaries """ all_members = [] next_page = None while True: params = {'limit': limit} if next_page: params['nextPage'] = next_page response = self.api.get('/v2/members', params=params) entries = response.get('entries', []) all_members.extend(entries) next_page = response.get('nextPage') if not next_page or not entries: break return all_members def get_member_by_email(self, email: str) -> Optional[Dict[str, Any]]: """ Find a member by email address (with full pagination) Args: email: Email address to search for Returns: Member dictionary if found, None otherwise """ members = self.list_all_members() for member in members: if member.get('email', '').lower() == email.lower(): return member return None def search_members_by_email(self, partial_email: str) -> List[Dict[str, Any]]: """ Search for members whose email contains the given string Args: partial_email: Partial email to search for Returns: List of matching members """ members = self.list_all_members() partial_lower = partial_email.lower() return [m for m in members if partial_lower in m.get('email', '').lower()] def list_files(self, type_filters: Optional[List[str]] = None, limit: int = 1000) -> List[Dict[str, Any]]: """ List all files with pagination Args: type_filters: Optional list of file types to filter (e.g., ['workbook', 'folder']) limit: Maximum number of files per page Returns: List of file dictionaries """ all_files = [] next_page = None while True: params = {'limit': limit} if next_page: params['nextPage'] = next_page if type_filters: # Sigma API v2 supports typeFilters parameter for server-side filtering # Client-side filtering is also applied as a fallback params['typeFilters'] = ','.join(type_filters) response = self.api.get('/v2/files', params=params) entries = response.get('entries', []) all_files.extend(entries) # Check for pagination next_page = response.get('nextPage') if not next_page or not entries: break # Client-side type filtering as fallback if API doesn't support typeFilters if type_filters: all_files = [f for f in all_files if f.get('type') in type_filters] return all_files def list_files_by_owner(self, owner_email: str, type_filters: Optional[List[str]] = None) -> List[Dict[str, Any]]: """ List files owned by a specific user (client-side filter) Note: This method loads all files into memory and filters client-side. For organizations with many files (10,000+), this may be slow and memory-intensive. The Sigma API does not currently support server-side filtering by ownerId. Args: owner_email: Email address of the owner type_filters: Optional list of file types to filter Returns: List of files owned by the specified user """ # First, resolve email to member ID (using paginated lookup) member = self.get_member_by_email(owner_email) if not member: raise ValueError(f"User not found: {owner_email}") owner_id = member.get('memberId') if not owner_id: raise ValueError(f"Could not get member ID for: {owner_email}") # Get all files and filter by owner all_files = self.list_files(type_filters=type_filters) owned_files = [f for f in all_files if f.get('ownerId') == owner_id] return owned_files def transfer_ownership(self, inode_id: str, new_owner_id: str) -> Dict[str, Any]: """ Transfer ownership of a single file Args: inode_id: ID of the file to transfer new_owner_id: Member ID of the new owner Returns: Updated file information """ data = {'ownerId': new_owner_id} return self.api.patch(f'/v2/files/{inode_id}', data) def _save_checkpoint(self, checkpoint_file: str, completed_ids: set) -> None: """Save checkpoint to file for recovery. Failures are logged but don't stop the transfer.""" checkpoint_data = { 'completed_ids': list(completed_ids), 'last_updated': datetime.now().isoformat() } try: with open(checkpoint_file, 'w') as f: json.dump(checkpoint_data, f, indent=2) except (OSError, IOError) as e: # Checkpoint failures should not stop the main transfer operation logging.warning(f"Failed to save checkpoint to '{checkpoint_file}': {e}") def bulk_transfer_ownership( self, files: List[Dict[str, Any]], new_owner_id: str, dry_run: bool = False, progress_callback: Optional[Callable[[int, int, str], None]] = None, rate_limit_delay: float = 0.5, max_retries: int = 3, checkpoint_file: Optional[str] = None ) -> List[TransferResult]: """ Transfer ownership of multiple files with results tracking Args: files: List of file dictionaries to transfer new_owner_id: Member ID of the new owner dry_run: If True, don't actually transfer, just simulate progress_callback: Optional callback for progress updates rate_limit_delay: Delay in seconds between API calls (default 0.5) max_retries: Maximum retry attempts for failed requests (default 3) checkpoint_file: Optional file path to save/resume progress Returns: List of TransferResult objects """ results = [] total = len(files) # Load checkpoint if exists completed_ids = set() if checkpoint_file and os.path.exists(checkpoint_file): try: with open(checkpoint_file, 'r') as f: checkpoint_data = json.load(f) completed_ids = set(checkpoint_data.get('completed_ids', [])) except (json.JSONDecodeError, IOError): pass # Start fresh if checkpoint is corrupted for i, file in enumerate(files, 1): inode_id = file.get('id') or file.get('inodeId') name = file.get('name', 'Unknown') file_type = file.get('type') if progress_callback: progress_callback(i, total, name) # Validate inode_id exists if not inode_id: results.append(TransferResult( inode_id='', name=name, success=False, file_type=file_type, error="Missing file ID (neither 'id' nor 'inodeId' found)" )) continue # Skip already completed (from checkpoint) if inode_id in completed_ids: results.append(TransferResult( inode_id=inode_id, name=name, success=True, file_type=file_type )) continue if dry_run: results.append(TransferResult( inode_id=inode_id, name=name, success=True, file_type=file_type )) continue # Retry logic with exponential backoff success = False last_error = None retryable_status_codes = {429, 500, 502, 503, 504} for retry in range(max_retries): try: self.transfer_ownership(inode_id, new_owner_id) success = True break except HTTPError as e: status_code = e.response.status_code if e.response is not None else None response_text = e.response.text[:200] if e.response is not None else '' # Retry on rate limiting and transient server errors if status_code in retryable_status_codes: if retry < max_retries - 1: wait_time = (2 ** retry) * rate_limit_delay time.sleep(wait_time) continue if status_code == 429: last_error = f"Rate limit exceeded (HTTP 429) after {max_retries} retries" else: last_error = f"Server error (HTTP {status_code}) after {max_retries} retries: {response_text}" elif status_code == 404: last_error = "File not found (HTTP 404) - may have been deleted" elif status_code == 403: last_error = "Permission denied (HTTP 403) - insufficient permissions" else: code_display = status_code if status_code is not None else 'unknown' last_error = f"HTTP {code_display}: {response_text}" break except Exception as e: last_error = f"{type(e).__name__}: {str(e)}" break if success: results.append(TransferResult( inode_id=inode_id, name=name, success=True, file_type=file_type )) # Save checkpoint after each success if checkpoint_file: completed_ids.add(inode_id) self._save_checkpoint(checkpoint_file, completed_ids) else: results.append(TransferResult( inode_id=inode_id, name=name, success=False, file_type=file_type, error=last_error )) # Rate limiting between requests if i < total and not dry_run: time.sleep(rate_limit_delay) return results def format_file_table(files: List[Dict[str, Any]]) -> str: """Format files as a table for display""" if not files: return " (no files found)" # Calculate column widths name_width = max(len(f.get('name', '')[:40]) for f in files) name_width = max(name_width, 4) # Minimum "Name" header type_width = max(len(f.get('type', '')) for f in files) type_width = max(type_width, 4) # Minimum "Type" header # Header lines = [] header = f" {'#':>3} {'Name':<{name_width}} {'Type':<{type_width}} Path" lines.append(header) lines.append(" " + "-" * (len(header) - 2)) # Rows for i, f in enumerate(files, 1): name = f.get('name', 'Unknown')[:40] file_type = f.get('type', 'unknown') path = f.get('path', '/') lines.append(f" {i:>3} {name:<{name_width}} {file_type:<{type_width}} {path}") return '\n'.join(lines) def main(): parser = argparse.ArgumentParser( description='Bulk transfer dashboard/workbook ownership in Sigma Computing', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s --from user@example.com --to newowner@example.com %(prog)s --from user@example.com --to newowner@example.com --type workbook %(prog)s --from user@example.com --to newowner@example.com --folder "My/Folder/Path" %(prog)s --from user@example.com --to newowner@example.com --dry-run %(prog)s --from user@example.com --to newowner@example.com --yes """ ) parser.add_argument( '--from', dest='from_email', required=True, help='Email of the current owner' ) parser.add_argument( '--to', dest='to_email', required=True, help='Email of the new owner' ) parser.add_argument( '--type', dest='file_type', choices=['workbook', 'folder', 'all'], default='workbook', help='Type of files to transfer (default: workbook)' ) parser.add_argument( '--folder', dest='folder_path', default=None, help='Only transfer files within this folder path (e.g., "Organization/Department/Content")' ) parser.add_argument( '--name', dest='name_prefix', default=None, help='Only transfer files whose name starts with this string (e.g., "Content -")' ) parser.add_argument( '--limit', type=int, default=None, help='Limit the number of files to transfer (useful for testing)' ) parser.add_argument( '--dry-run', action='store_true', help='Show what would be transferred without making changes' ) parser.add_argument( '--yes', '-y', action='store_true', help='Skip confirmation prompt' ) parser.add_argument( '--rate-limit', dest='rate_limit', type=float, default=0.5, help='Delay in seconds between API calls (default: 0.5)' ) parser.add_argument( '--checkpoint', dest='checkpoint_file', default=None, help='File to save/resume transfer progress (enables recovery from failures)' ) parser.add_argument( '--log-file', dest='log_file', default=None, help='File to write audit log (enables detailed logging for compliance)' ) args = parser.parse_args() # Setup logging if requested log_file = setup_logging(args.log_file) if log_file: logging.info(f"Starting ownership transfer: {args.from_email} -> {args.to_email}") logging.info(f"Options: type={args.file_type}, folder={args.folder_path}, " f"name_prefix={args.name_prefix}, dry_run={args.dry_run}") # Validate source and target are different if args.from_email.lower() == args.to_email.lower(): print("Error: Source and target email addresses are the same; nothing to transfer.") sys.exit(1) # Validate limit is positive if args.limit is not None and args.limit <= 0: print("Error: --limit must be a positive integer.") sys.exit(1) # Validate rate_limit is non-negative if args.rate_limit < 0: print("Error: --rate-limit must be a non-negative value.") sys.exit(1) # Determine type filters if args.file_type == 'all': type_filters = OwnershipManager.ALL_TYPES else: type_filters = [args.file_type] # Initialize API client try: config_manager = ConfigManager() sigma_config = config_manager.get_sigma_config() api_client = SigmaAPIClient(sigma_config) except ValueError as e: print(f"Configuration error: {e}") print("Set environment variables: SIGMA_CLIENT_ID, SIGMA_CLIENT_SECRET, SIGMA_BASE_URL") sys.exit(1) ownership_manager = OwnershipManager(api_client) # Resolve target user (using paginated lookup) print(f"Resolving target user: {args.to_email}...") target_member = ownership_manager.get_member_by_email(args.to_email) if not target_member: # Try partial search to help user find correct email print(f"Error: Target user not found: {args.to_email}") partial = args.to_email.split('@')[0] similar = ownership_manager.search_members_by_email(partial) if similar: print(f"\nDid you mean one of these?") for m in similar[:5]: print(f" - {m.get('email')} ({m.get('firstName', '')} {m.get('lastName', '')})") sys.exit(1) target_id = target_member.get('memberId') if not target_id: print(f"Error: Target user record for {args.to_email} is missing 'memberId'.") sys.exit(1) target_name = f"{target_member.get('firstName', '')} {target_member.get('lastName', '')}".strip() print(f" Found: {target_name} ({target_id})") # Find files owned by source user print(f"\nSearching for files owned by {args.from_email}...") try: files = ownership_manager.list_files_by_owner(args.from_email, type_filters=type_filters) except ValueError as e: print(f"Error: {e}") sys.exit(1) # Filter by folder path if specified if args.folder_path: folder_filter = args.folder_path.rstrip('/') folder_filter_with_sep = folder_filter + '/' # Match exact folder or subfolders (avoid matching "MyFolderExtra" when filtering for "MyFolder") files = [ f for f in files if f.get('path', '') == folder_filter or f.get('path', '').startswith(folder_filter_with_sep) ] print(f"Filtered to folder: {folder_filter}") # Filter by name prefix if specified if args.name_prefix: files = [f for f in files if f.get('name', '').startswith(args.name_prefix)] print(f"Filtered to names starting with: {args.name_prefix}") # Apply limit if specified total_available = len(files) if args.limit and args.limit < len(files): files = files[:args.limit] print(f"Limited to first {args.limit} of {total_available} files") if not files: msg = f"No {args.file_type} files found owned by {args.from_email}" if args.folder_path: msg += f" in folder {args.folder_path}" print(msg) sys.exit(0) # Display files to be transferred type_label = 'files' if args.file_type == 'all' else f'{args.file_type}s' print(f"Found {len(files)} {type_label} owned by {args.from_email}:\n") print(format_file_table(files)) print() # Dry run mode if args.dry_run: print(f"[DRY RUN] Would transfer {len(files)} items to {args.to_email}") print("No changes were made.") sys.exit(0) # Confirmation if not args.yes: response = input(f"Transfer ownership of {len(files)} items to {args.to_email}? [y/N]: ") if response.lower() not in ['y', 'yes']: print("Aborted.") sys.exit(0) # Execute transfer print("\nTransferring ownership...") def print_progress(current: int, total: int, name: str): # Clear line and print status print(f"\r [{current}/{total}] {name[:50]:<50}", end='', flush=True) results = ownership_manager.bulk_transfer_ownership( files, target_id, dry_run=False, progress_callback=print_progress, rate_limit_delay=args.rate_limit, checkpoint_file=args.checkpoint_file ) # Print final newline after progress print() # Summary successful = [r for r in results if r.success] failed = [r for r in results if not r.success] print(f"\nTransfer complete:") print(f" - Successful: {len(successful)}") print(f" - Failed: {len(failed)}") # Log summary if log_file: logging.info(f"Transfer complete: {len(successful)} successful, {len(failed)} failed") for r in successful: logging.info(f"SUCCESS: {r.name} ({r.inode_id}) - {r.file_type}") for r in failed: logging.error(f"FAILED: {r.name} ({r.inode_id}) - {r.file_type} - {r.error}") logging.info(f"Audit log saved to: {log_file}") print(f"\nAudit log saved to: {log_file}") # Show failed transfers if failed: print("\nFailed transfers:") for r in failed: type_info = f" ({r.file_type})" if r.file_type else "" print(f" - {r.name}{type_info}: {r.error}") sys.exit(1) if __name__ == '__main__': main()