├── utils ├── __init__.py ├── misc.py ├── config.py └── google.py ├── README.md ├── requirements.txt ├── .gitignore └── sa_maker.py /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # service_account_maker 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ansimarkup==1.4.0 2 | attrdict==2.0.1 3 | better-exceptions-fork==0.2.1.post6 4 | certifi==2018.11.29 5 | chardet==3.0.4 6 | Click==7.0 7 | colorama==0.4.1 8 | idna==2.8 9 | loguru==0.2.5 10 | oauthlib==3.0.1 11 | Pygments==2.3.1 12 | requests==2.21.0 13 | requests-oauthlib==1.2.0 14 | six==1.12.0 15 | urllib3==1.24.1 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific stuff: 2 | .idea 3 | 4 | ## File-based project format: 5 | *.iws 6 | 7 | # IntelliJ 8 | /out/ 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | *.pyc 15 | 16 | # logs 17 | *.log* 18 | 19 | # databases 20 | *.db 21 | 22 | # configs 23 | *.cfg 24 | *.json 25 | 26 | # generators 27 | *.bat 28 | 29 | # Pyenv 30 | **/.python-version 31 | 32 | # Venv 33 | venv/ 34 | 35 | # PyInstaller 36 | build/ 37 | dist/ 38 | *.manifest 39 | *.spec 40 | 41 | # Other 42 | service_accounts/ -------------------------------------------------------------------------------- /utils/misc.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import os 4 | 5 | from loguru import logger 6 | 7 | 8 | def dump_service_file(file_path, service_file): 9 | try: 10 | decoded_key = json.loads(base64.b64decode(service_file['privateKeyData'])) 11 | with open(file_path, 'w') as fp: 12 | json.dump(decoded_key, fp, indent=2) 13 | return True 14 | except Exception: 15 | logger.exception(f"Exception dumping service file to {file_path!r}: ") 16 | return False 17 | 18 | 19 | def get_starting_account_number(service_key_folder): 20 | try: 21 | files = [os.path.join(service_key_folder, f) for f in os.listdir(service_key_folder) if 22 | os.path.isfile(os.path.join(service_key_folder, f))] 23 | return len(files) + 1 24 | except Exception: 25 | logger.exception(f"Exception determining starting account number from {service_key_folder!r}: ") 26 | return None 27 | 28 | 29 | def get_service_account_users(service_key_folder): 30 | try: 31 | service_key_users = [] 32 | files = [os.path.join(service_key_folder, f) for f in os.listdir(service_key_folder) if 33 | os.path.isfile(os.path.join(service_key_folder, f))] 34 | if not len(files): 35 | logger.error(f"There were no service key files found in {service_key_folder!r}") 36 | return None 37 | 38 | for service_key_file in files: 39 | service_key_data = {} 40 | with open(service_key_file, 'r') as fp: 41 | service_key_data = json.load(fp) 42 | 43 | if 'client_email' not in service_key_data: 44 | logger.warning(f"Unable to retrieve client_email from: {service_key_file!r}, skipping...") 45 | continue 46 | 47 | service_key_email = service_key_data['client_email'] 48 | if service_key_email not in service_key_users: 49 | service_key_users.append(service_key_email) 50 | 51 | return service_key_users 52 | 53 | except Exception: 54 | logger.exception(f"Exception determining user(s) of service keys in {service_key_folder!r}: ") 55 | return None 56 | 57 | 58 | def get_teamdrive_id(teamdrives, teamdrive_name): 59 | try: 60 | for teamdrive in teamdrives['teamDrives']: 61 | if teamdrive['name'].lower() == teamdrive_name.lower(): 62 | logger.trace(f"Found teamdrive_id {teamdrive['id']!r} for teamdrive_name {teamdrive_name!r}") 63 | return teamdrive['id'] 64 | logger.error(f"Failed to find teamdrive_id with name {teamdrive_name!r}") 65 | return None 66 | except Exception: 67 | logger.exception(f"Exception retrieving teamdrive_id for teamdrive_name {teamdrive_name}: ") 68 | return None 69 | 70 | 71 | def get_group_id(groups, group_name, group_email=None): 72 | try: 73 | if 'groups' not in groups: 74 | return None 75 | 76 | for group in groups['groups']: 77 | if group['name'].lower() == group_name.lower(): 78 | if group_email is not None and group['email'].lower() != group_email.lower(): 79 | continue 80 | 81 | logger.trace(f"Found group_id {group['id']!r} for group_name {group_name!r}") 82 | return group['id'] 83 | logger.error(f"Failed to find group_id with name {group_name!r}") 84 | return None 85 | except Exception: 86 | logger.exception(f"Exception retrieving group_id for group_name {group_name}: ") 87 | return None 88 | 89 | 90 | def is_safe_email(safe_emails, check_email): 91 | for safe_email in safe_emails: 92 | if check_email.lower() == safe_email.lower(): 93 | return True 94 | return False 95 | -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | from collections import OrderedDict 5 | 6 | from attrdict import AttrDict 7 | 8 | json.encoder.c_make_encoder = None 9 | 10 | 11 | class Singleton(type): 12 | _instances = {} 13 | 14 | def __call__(cls, *args, **kwargs): 15 | if cls not in cls._instances: 16 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 17 | 18 | return cls._instances[cls] 19 | 20 | 21 | class AttrConfig(AttrDict): 22 | """ 23 | Simple AttrDict subclass to return None when requested attribute does not exist 24 | """ 25 | 26 | def __init__(self, config): 27 | super().__init__(config) 28 | 29 | def __getattr__(self, item): 30 | try: 31 | return super().__getattr__(item) 32 | except AttributeError: 33 | pass 34 | # Default behaviour 35 | return None 36 | 37 | 38 | class Config(object, metaclass=Singleton): 39 | base_config = OrderedDict({ 40 | 'client_id': '', 41 | 'client_secret': '', 42 | 'project_name': '', 43 | 'service_account_folder': os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "service_accounts") 44 | }) 45 | 46 | def __init__(self, config_path): 47 | """Initializes config""" 48 | self.conf = OrderedDict({}) 49 | 50 | self.config_path = config_path 51 | 52 | @property 53 | def cfg(self): 54 | # Return existing loaded config 55 | if self.conf: 56 | return self.conf 57 | 58 | # Built initial config if it doesn't exist 59 | if self.build_config(): 60 | print("Please edit the default configuration before running again!") 61 | sys.exit(0) 62 | # Load config, upgrade if necessary 63 | else: 64 | tmp = self.load_config() 65 | self.conf, upgraded = self.upgrade_settings(tmp) 66 | 67 | # Save config if upgraded 68 | if upgraded: 69 | self.dump_config() 70 | print("New config options were added, adjust and restart!") 71 | sys.exit(0) 72 | 73 | return self.conf 74 | 75 | @property 76 | def default_config(self): 77 | config = self.base_config 78 | return config 79 | 80 | def build_config(self): 81 | if not os.path.exists(self.config_path): 82 | print("Dumping default config to: %s" % self.config_path) 83 | with open(self.config_path, 'w') as fp: 84 | json.dump(self.default_config, fp, sort_keys=False, indent=2, default=str) 85 | return True 86 | else: 87 | return False 88 | 89 | def dump_config(self): 90 | if os.path.exists(self.config_path): 91 | with open(self.config_path, 'w') as fp: 92 | json.dump(self.conf, fp, sort_keys=False, indent=2, default=str) 93 | return True 94 | else: 95 | return False 96 | 97 | def load_config(self): 98 | with open(self.config_path, 'r') as fp: 99 | return AttrConfig(json.load(fp, object_hook=OrderedDict)) 100 | 101 | def __inner_upgrade(self, settings1, settings2, key=None, overwrite=False): 102 | sub_upgraded = False 103 | merged = settings2.copy() 104 | 105 | if isinstance(settings1, dict): 106 | for k, v in settings1.items(): 107 | # missing k 108 | if k not in settings2: 109 | merged[k] = v 110 | sub_upgraded = True 111 | if not key: 112 | print("Added %r config option: %s" % (str(k), str(v))) 113 | else: 114 | print("Added %r to config option %r: %s" % (str(k), str(key), str(v))) 115 | continue 116 | 117 | # iterate children 118 | if isinstance(v, dict) or isinstance(v, list): 119 | merged[k], did_upgrade = self.__inner_upgrade(settings1[k], settings2[k], key=k, 120 | overwrite=overwrite) 121 | sub_upgraded = did_upgrade if did_upgrade else sub_upgraded 122 | elif settings1[k] != settings2[k] and overwrite: 123 | merged = settings1 124 | sub_upgraded = True 125 | elif isinstance(settings1, list) and key: 126 | for v in settings1: 127 | if v not in settings2: 128 | merged.append(v) 129 | sub_upgraded = True 130 | print("Added to config option %r: %s" % (str(key), str(v))) 131 | continue 132 | 133 | return merged, sub_upgraded 134 | 135 | def upgrade_settings(self, currents): 136 | upgraded_settings, upgraded = self.__inner_upgrade(self.base_config, currents) 137 | return AttrConfig(upgraded_settings), upgraded 138 | 139 | def merge_settings(self, settings_to_merge): 140 | upgraded_settings, upgraded = self.__inner_upgrade(settings_to_merge, self.conf, overwrite=True) 141 | 142 | self.conf = upgraded_settings 143 | 144 | if upgraded: 145 | self.dump_config() 146 | 147 | return AttrConfig(upgraded_settings), upgraded 148 | -------------------------------------------------------------------------------- /utils/google.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from threading import Lock 4 | from time import time 5 | 6 | from loguru import logger 7 | from requests_oauthlib import OAuth2Session 8 | 9 | 10 | class Google: 11 | auth_url = 'https://accounts.google.com/o/oauth2/v2/auth' 12 | token_url = 'https://www.googleapis.com/oauth2/v4/token' 13 | api_url = 'https://iam.googleapis.com/v1/' 14 | redirect_url = 'urn:ietf:wg:oauth:2.0:oob' 15 | scopes = ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/drive', 16 | 'https://www.googleapis.com/auth/admin.directory.group'] 17 | 18 | def __init__(self, client_id, client_secret, project_name, token_path): 19 | self.client_id = client_id 20 | self.client_secret = client_secret 21 | self.project_name = project_name 22 | self.token_path = token_path 23 | self.token = self._load_token() 24 | self.token_refresh_lock = Lock() 25 | self.http = self._new_http_object() 26 | 27 | ############################################################ 28 | # CORE CLASS METHODS 29 | ############################################################ 30 | 31 | def get_auth_link(self): 32 | auth_url, state = self.http.authorization_url(self.auth_url, access_type='offline', prompt='select_account') 33 | return auth_url 34 | 35 | def exchange_code(self, code): 36 | token = self.http.fetch_token(self.token_url, code=code, client_secret=self.client_secret) 37 | if 'access_token' in token: 38 | self._token_saver(token) 39 | return self.token 40 | 41 | def query(self, path, method='GET', page_type='changes', fetch_all_pages=False, **kwargs): 42 | resp_json = {} 43 | pages = 1 44 | resp = None 45 | request_url = self.api_url + path.lstrip('/') if not path.startswith('http') else path 46 | 47 | try: 48 | while True: 49 | resp = self._do_query(request_url, method, **kwargs) 50 | logger.debug(f"Request URL: {resp.url}") 51 | logger.debug(f"Request ARG: {kwargs}") 52 | logger.debug(f'Response Status: {resp.status_code} {resp.reason}') 53 | 54 | if 'Content-Type' in resp.headers and 'json' in resp.headers['Content-Type']: 55 | if fetch_all_pages: 56 | resp_json.pop('nextPageToken', None) 57 | new_json = resp.json() 58 | # does this page have changes 59 | extended_pages = False 60 | page_data = [] 61 | if page_type in new_json: 62 | if page_type in resp_json: 63 | page_data.extend(resp_json[page_type]) 64 | page_data.extend(new_json[page_type]) 65 | extended_pages = True 66 | 67 | resp_json.update(new_json) 68 | if extended_pages: 69 | resp_json[page_type] = page_data 70 | else: 71 | return False if resp.status_code != 200 else True, resp, resp.text 72 | 73 | # handle nextPageToken 74 | if fetch_all_pages and 'nextPageToken' in resp_json and resp_json['nextPageToken']: 75 | # there are more pages 76 | pages += 1 77 | logger.info(f"Fetching extra results from page {pages}") 78 | if 'params' in kwargs: 79 | kwargs['params'].update({'pageToken': resp_json['nextPageToken']}) 80 | elif 'json' in kwargs: 81 | kwargs['json'].update({'pageToken': resp_json['nextPageToken']}) 82 | elif 'data' in kwargs: 83 | kwargs['data'].update({'pageToken': resp_json['nextPageToken']}) 84 | continue 85 | 86 | break 87 | 88 | return True if resp_json and len(resp_json) else False, resp, resp_json if ( 89 | resp_json and len(resp_json)) else resp.text 90 | 91 | except Exception: 92 | logger.exception(f"Exception sending request to {request_url} with kwargs={kwargs}: ") 93 | return False, resp, None 94 | 95 | ############################################################ 96 | # GOOGLE METHODS 97 | ############################################################ 98 | 99 | def get_groups(self): 100 | success, resp, resp_data = self.query(f'https://www.googleapis.com/admin/directory/v1/groups', params={ 101 | 'customer': 'my_customer', 102 | 'maxResults': 200 103 | }, fetch_all_pages=True, page_type='groups') 104 | return True if resp.status_code == 200 else False, resp_data 105 | 106 | def create_group(self, name, domain): 107 | success, resp, resp_data = self.query(f'https://www.googleapis.com/admin/directory/v1/groups', 'POST', json={ 108 | 'name': name, 109 | 'email': f'{name}@{domain}' 110 | }) 111 | return True if resp.status_code == 200 else False, resp_data 112 | 113 | def delete_group(self, group_id): 114 | success, resp, resp_data = self.query(f'https://www.googleapis.com/admin/directory/v1/groups/{group_id}', 115 | 'DELETE') 116 | return True if resp.status_code == 204 else False, resp_data 117 | 118 | def get_group_users(self, group_id): 119 | success, resp, resp_data = self.query( 120 | f'https://www.googleapis.com/admin/directory/v1/groups/{group_id}/members', params={ 121 | 'maxResults': 200 122 | }, fetch_all_pages=True, page_type='members') 123 | return True if resp.status_code == 200 else False, resp_data 124 | 125 | def set_group_user(self, group_id, email): 126 | success, resp, resp_data = self.query( 127 | f'https://www.googleapis.com/admin/directory/v1/groups/{group_id}/members', 'POST', json={ 128 | 'email': email, 129 | 'role': 'MEMBER' 130 | }) 131 | return True if resp.status_code == 200 else False, resp_data 132 | 133 | def get_service_accounts(self): 134 | success, resp, resp_data = self.query(f'projects/{self.project_name}/serviceAccounts', fetch_all_pages=True, 135 | page_type='accounts', params={'pageSize': 100}) 136 | return success, resp_data 137 | 138 | def get_service_account_keys(self, service_account): 139 | success, resp, resp_data = self.query(f'projects/{self.project_name}/serviceAccounts/{service_account}/keys', 140 | fetch_all_pages=True, page_type='keys', params={'pageSie': 100}) 141 | return success, resp_data 142 | 143 | def create_service_account(self, name): 144 | success, resp, resp_data = self.query(f'projects/{self.project_name}/serviceAccounts', 'POST', json={ 145 | 'accountId': f'{name}' 146 | }) 147 | return success, resp_data 148 | 149 | def create_service_account_key(self, name): 150 | success, resp, resp_data = self.query(f'projects/{self.project_name}/serviceAccounts/{name}/keys', 'POST', 151 | json={ 152 | 'privateKeyType': 'TYPE_GOOGLE_CREDENTIALS_FILE', 153 | 'keyAlgorithm': 'KEY_ALG_RSA_2048' 154 | }) 155 | return success, resp_data 156 | 157 | def get_teamdrives(self): 158 | success, resp, resp_data = self.query('https://www.googleapis.com/drive/v3/teamdrives', 159 | params={'pageSize': 100}, fetch_all_pages=True, page_type='teamDrives') 160 | return success, resp_data 161 | 162 | def create_teamdrive(self, name): 163 | success, resp, resp_data = self.query(f'https://www.googleapis.com/drive/v3/teamdrives', 'POST', 164 | params={'requestId': name}, json={'name': name}) 165 | return success, resp_data 166 | 167 | def get_teamdrive_permissions(self, teamdrive_id): 168 | success, resp, resp_data = self.query(f'https://www.googleapis.com/drive/v3/files/{teamdrive_id}/permissions', 169 | params={'pageSize': 100, 170 | 'fields': 'permissions(deleted,domain,emailAddress,id,type)', 171 | 'supportsTeamDrives': True}, 172 | fetch_all_pages=True, page_type='permissions') 173 | return success, resp_data 174 | 175 | def set_teamdrive_share_user(self, teamdrive_id, user): 176 | success, resp, resp_data = self.query(f'https://www.googleapis.com/drive/v3/files/{teamdrive_id}/permissions', 177 | 'POST', params={'supportsTeamDrives': True}, 178 | json={'role': 'fileOrganizer', 179 | 'type': 'user', 180 | 'emailAddress': user 181 | }) 182 | return success, resp_data 183 | 184 | def delete_teamdrive_share_user(self, teamdrive_id, permission_id): 185 | success, resp, resp_data = self.query( 186 | f'https://www.googleapis.com/drive/v3/files/{teamdrive_id}/permissions/{permission_id}', 187 | 'DELETE', params={'supportsTeamDrives': True}) 188 | return True if resp.status_code == 204 else False, resp_data 189 | 190 | ############################################################ 191 | # INTERNALS 192 | ############################################################ 193 | 194 | def _do_query(self, request_url, method, **kwargs): 195 | tries = 0 196 | max_tries = 2 197 | lock_acquirer = False 198 | resp = None 199 | use_timeout = 30 200 | 201 | # override default timeout 202 | if 'timeout' in kwargs and isinstance(kwargs['timeout'], int): 203 | use_timeout = kwargs['timeout'] 204 | kwargs.pop('timeout', None) 205 | 206 | # do query 207 | while tries < max_tries: 208 | if self.token_refresh_lock.locked() and not lock_acquirer: 209 | logger.debug("Token refresh lock is currently acquired... trying again in 500ms") 210 | time.sleep(0.5) 211 | continue 212 | 213 | if method == 'POST': 214 | resp = self.http.post(request_url, timeout=use_timeout, **kwargs) 215 | elif method == 'PATCH': 216 | resp = self.http.patch(request_url, timeout=use_timeout, **kwargs) 217 | elif method == 'DELETE': 218 | resp = self.http.delete(request_url, timeout=use_timeout, **kwargs) 219 | else: 220 | resp = self.http.get(request_url, timeout=use_timeout, **kwargs) 221 | tries += 1 222 | 223 | if resp.status_code == 401 and tries < max_tries: 224 | # unauthorized error, lets refresh token and retry 225 | self.token_refresh_lock.acquire(False) 226 | lock_acquirer = True 227 | logger.warning(f"Unauthorized Response (Attempts {tries}/{max_tries})") 228 | self.token['expires_at'] = time() - 10 229 | self.http = self._new_http_object() 230 | else: 231 | break 232 | 233 | return resp 234 | 235 | def _load_token(self): 236 | try: 237 | if not os.path.exists(self.token_path): 238 | return {} 239 | 240 | with open(self.token_path, 'r') as fp: 241 | return json.load(fp) 242 | except Exception: 243 | logger.exception(f"Exception loading token from {self.token_path}: ") 244 | return {} 245 | 246 | def _dump_token(self): 247 | try: 248 | with open(self.token_path, 'w') as fp: 249 | json.dump(self.token, fp, indent=2) 250 | return True 251 | except Exception: 252 | logger.exception(f"Exception dumping token to {self.token_path}: ") 253 | return False 254 | 255 | def _token_saver(self, token): 256 | # update internal token dict 257 | self.token.update(token) 258 | try: 259 | if self.token_refresh_lock.locked(): 260 | self.token_refresh_lock.release() 261 | except Exception: 262 | logger.exception("Exception releasing token_refresh_lock: ") 263 | self._dump_token() 264 | logger.info("Renewed access token!") 265 | return 266 | 267 | def _new_http_object(self): 268 | return OAuth2Session(client_id=self.client_id, redirect_uri=self.redirect_url, scope=self.scopes, 269 | auto_refresh_url=self.token_url, auto_refresh_kwargs={'client_id': self.client_id, 270 | 'client_secret': self.client_secret}, 271 | token_updater=self._token_saver, token=self.token) 272 | -------------------------------------------------------------------------------- /sa_maker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | import os 4 | import sys 5 | from copy import copy 6 | 7 | import click 8 | from loguru import logger 9 | 10 | from utils import misc 11 | from utils.google import Google 12 | 13 | ############################################################ 14 | # INIT 15 | ############################################################ 16 | 17 | # Globals 18 | cfg = None 19 | google = None 20 | 21 | 22 | # Click 23 | @click.group(help='service_account_maker') 24 | @click.version_option('0.0.1', prog_name='service_account_maker') 25 | @click.option('-v', '--verbose', count=True, default=0, help='Adjust the logging level') 26 | @click.option( 27 | '--config-path', 28 | envvar='SA_MAKER_CONFIG_PATH', 29 | type=click.Path(file_okay=True, dir_okay=False), 30 | help='Configuration filepath', 31 | show_default=True, 32 | default=os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "config.json") 33 | ) 34 | @click.option( 35 | '--log-path', 36 | envvar='SA_MAKER_LOG_PATH', 37 | type=click.Path(file_okay=True, dir_okay=False), 38 | help='Log filepath', 39 | show_default=True, 40 | default=os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "activity.log") 41 | ) 42 | @click.option( 43 | '--token-path', 44 | envvar='SA_MAKER_TOKEN_PATH', 45 | type=click.Path(file_okay=True, dir_okay=False), 46 | help='Token filepath', 47 | show_default=True, 48 | default=os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "token.json") 49 | ) 50 | def app(verbose, config_path, log_path, token_path): 51 | global cfg, google 52 | 53 | # Ensure paths are full paths 54 | if not config_path.startswith(os.path.sep): 55 | config_path = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), config_path) 56 | if not log_path.startswith(os.path.sep): 57 | log_path = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), log_path) 58 | if not token_path.startswith(os.path.sep): 59 | token_path = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), token_path) 60 | 61 | # Load config 62 | from utils.config import Config 63 | cfg = Config(config_path=config_path).cfg 64 | 65 | # Load logger 66 | log_levels = {0: 'INFO', 1: 'DEBUG', 2: 'TRACE'} 67 | log_level = log_levels[verbose] if verbose in log_levels else 'TRACE' 68 | config_logger = { 69 | 'handlers': [ 70 | {'sink': sys.stdout, 'backtrace': True if verbose >= 2 else False, 'level': log_level}, 71 | {'sink': log_path, 72 | 'rotation': '30 days', 73 | 'retention': '7 days', 74 | 'enqueue': True, 75 | 'backtrace': True if verbose >= 2 else False, 76 | 'level': log_level} 77 | ] 78 | } 79 | logger.configure(**config_logger) 80 | 81 | # Load google 82 | google = Google(cfg.client_id, cfg.client_secret, cfg.project_name, token_path) 83 | 84 | # Display params 85 | logger.info("%s = %r" % ("LOG_PATH".ljust(12), log_path)) 86 | logger.info("%s = %r" % ("LOG_LEVEL".ljust(12), log_level)) 87 | logger.info("") 88 | return 89 | 90 | 91 | ############################################################ 92 | # CLICK FUNCTIONS 93 | ############################################################ 94 | 95 | @app.command(help='Authorize Google Account') 96 | def authorize(): 97 | global google, cfg 98 | 99 | logger.debug(f"client_id: {cfg.client_id!r}") 100 | logger.debug(f"client_secret: {cfg.client_secret!r}") 101 | 102 | # Provide authorization link 103 | logger.info("Visit the link below and paste the authorization code") 104 | logger.info(google.get_auth_link()) 105 | logger.info("Enter authorization code: ") 106 | auth_code = input() 107 | logger.debug(f"auth_code: {auth_code!r}") 108 | 109 | # Exchange authorization code 110 | token = google.exchange_code(auth_code) 111 | if not token or 'access_token' not in token: 112 | logger.error("Failed exchanging authorization code for an access token....") 113 | sys.exit(1) 114 | else: 115 | logger.info(f"Exchanged authorization code for an access token:\n\n{json.dumps(token, indent=2)}\n") 116 | sys.exit(0) 117 | 118 | 119 | @app.command(help='Retrieve existing groups') 120 | def list_groups(): 121 | global google, cfg 122 | 123 | # retrieve groups 124 | logger.info("Retrieving existing groups...") 125 | success, groups = google.get_groups() 126 | if success: 127 | logger.info(f"Existing groups:\n{json.dumps(groups, indent=2)}") 128 | sys.exit(0) 129 | else: 130 | logger.error(f"Failed to retrieve groups:\n{groups}") 131 | sys.exit(1) 132 | 133 | 134 | @app.command(help='Create group') 135 | @click.option('--name', '-n', required=True, help='Name of the group') 136 | @click.option('--domain', '-d', required=True, help='Domain of the G Suite account') 137 | def create_group(name, domain): 138 | global google, cfg 139 | 140 | # create group 141 | logger.info(f"Creating group named: {name} - {name}@{domain}") 142 | 143 | success, group = google.create_group(name, domain) 144 | if success: 145 | logger.info(f"Created group {name!r}:\n{group}") 146 | sys.exit(0) 147 | else: 148 | logger.error(f"Failed to create group {name!r}:\n{group}") 149 | sys.exit(1) 150 | 151 | 152 | @app.command(help='Remove a group') 153 | @click.option('--name', '-n', required=True, help='Name of the group') 154 | @click.option('--domain', '-d', required=True, help='Domain of the G Suite account') 155 | def remove_group(name, domain): 156 | global google, cfg 157 | 158 | # retrieve group id 159 | success, groups = google.get_groups() 160 | if not success: 161 | logger.error(f"Unable to retrieve existing groups:\n{groups}") 162 | sys.exit(1) 163 | 164 | group_id = misc.get_group_id(groups, name, f'{name}@{domain}') 165 | if not group_id: 166 | logger.error(f"Failed to determine group_id of group with name {name!r}") 167 | sys.exit(1) 168 | 169 | # remove group 170 | logger.info(f"Removing group: {name} - {name}@{domain}") 171 | success, resp = google.delete_group(group_id) 172 | if success: 173 | logger.info(f"Deleted group!") 174 | sys.exit(0) 175 | else: 176 | logger.error(f"Failed removing group {name!r} - {name}@{domain}:\n{resp}") 177 | sys.exit(1) 178 | 179 | 180 | @app.command(help='Set users for a group') 181 | @click.option('--name', '-n', required=True, help='Name of the existing group') 182 | @click.option('--key-prefix', '-k', required=True, help='Name prefix of service accounts') 183 | def set_group_users(name, key_prefix): 184 | global google, cfg 185 | 186 | # validate the service key folder exists 187 | service_key_folder = os.path.join(cfg.service_account_folder, key_prefix) 188 | if not os.path.exists(service_key_folder): 189 | logger.error(f"The service key folder did not exist at: {service_key_folder}") 190 | sys.exit(1) 191 | 192 | # retrieve service key users to share teamdrive access with 193 | service_key_users = misc.get_service_account_users(service_key_folder) 194 | if service_key_users is None: 195 | logger.error(f"Failed to determine the service key user(s) to add to group: {name}") 196 | sys.exit(1) 197 | 198 | # retrieve group id 199 | success, groups = google.get_groups() 200 | if not success: 201 | logger.error(f"Unable to retrieve existing groups:\n{groups}") 202 | sys.exit(1) 203 | 204 | group_id = misc.get_group_id(groups, name) 205 | if not group_id: 206 | logger.error(f"Failed to determine group_id of group with name {name!r}") 207 | sys.exit(1) 208 | 209 | # retrieve group members 210 | success, group_members = google.get_group_users(group_id) 211 | if not success: 212 | logger.error(f"Failed retrieving users in group with name {name!r}:\n{group_members}") 213 | sys.exit(1) 214 | 215 | # remove users that are already a member 216 | if 'members' in group_members: 217 | for member in group_members['members']: 218 | if member['email'] in service_key_users: 219 | service_key_users.remove(member['email']) 220 | 221 | if not len(service_key_users): 222 | logger.info(f"There were no service key users to add to group with name {name!r}") 223 | sys.exit(0) 224 | 225 | # add user to group 226 | logger.info( 227 | f"Adding {len(service_key_users)} users to {name!r} group, user(s): {service_key_users}") 228 | 229 | for service_key_user in service_key_users: 230 | success, resp = google.set_group_user(group_id, service_key_user) 231 | if success: 232 | logger.info(f"Added user to {name!r} group: {service_key_user}") 233 | else: 234 | logger.error(f"Failed adding user to {name!r} group for user {service_key_user!r}:\n{resp}") 235 | sys.exit(1) 236 | sys.exit(0) 237 | 238 | 239 | @app.command(help='Lists users for a group') 240 | @click.option('--name', '-n', required=True, help='Name of the group') 241 | def list_group_users(name): 242 | global google, cfg 243 | 244 | # retrieve the group id 245 | success, groups = google.get_groups() 246 | if not success: 247 | logger.error(f"Unable to retrieve existing groups:\n{groups}") 248 | sys.exit(1) 249 | 250 | group_id = misc.get_group_id(groups, name) 251 | if not group_id: 252 | logger.error(f"Failed to determine group_id of group with name {name!r}") 253 | sys.exit(1) 254 | 255 | # get group members 256 | success, group_members = google.get_group_users(group_id) 257 | if success: 258 | logger.info(f"Existing users on group {name!r}:\n{json.dumps(group_members, indent=2)}") 259 | sys.exit(0) 260 | else: 261 | logger.error(f"Failed retrieving users in group with name {name!r}:\n{group_members}") 262 | sys.exit(1) 263 | 264 | 265 | @app.command(help='Retrieve existing service accounts') 266 | def list_accounts(): 267 | global google, cfg 268 | 269 | # retrieve service accounts 270 | logger.info("Retrieving existing service accounts...") 271 | success, service_accounts = google.get_service_accounts() 272 | if success: 273 | logger.info(f"Existing service accounts:\n{json.dumps(service_accounts, indent=2)}") 274 | sys.exit(0) 275 | else: 276 | logger.error(f"Failed to retrieve service accounts:\n{service_accounts}") 277 | sys.exit(1) 278 | 279 | 280 | @app.command(help='Create service accounts') 281 | @click.option('--name', '-n', required=True, help='Name prefix for service accounts') 282 | @click.option('--amount', '-a', default=1, required=False, help='Amount of service accounts to create') 283 | def create_accounts(name, amount=1): 284 | global google, cfg 285 | 286 | service_key_folder = os.path.join(cfg.service_account_folder, name) 287 | 288 | # does service key subfolder exist? 289 | if not os.path.exists(service_key_folder): 290 | logger.debug(f"Creating service key path: {service_key_folder!r}") 291 | if os.makedirs(service_key_folder, exist_ok=True): 292 | logger.info(f"Created service key path: {service_key_folder!r}") 293 | 294 | # count amount of service files that exist already in this folder 295 | starting_account_number = misc.get_starting_account_number(service_key_folder) 296 | if not starting_account_number: 297 | logger.error(f"Failed to determining the account number to start from....") 298 | sys.exit(1) 299 | 300 | for account_number in range(starting_account_number, starting_account_number + amount): 301 | account_name = f'{name}{account_number:06d}' 302 | 303 | # create the service account 304 | success, service_account = google.create_service_account(account_name) 305 | if success and (isinstance(service_account, dict) and 'email' in service_account and 306 | 'uniqueId' in service_account): 307 | account_email = service_account['email'] 308 | logger.info(f"Created service account: {account_email!r}") 309 | 310 | # create key for new service account 311 | success, service_key = google.create_service_account_key(account_email) 312 | if success and (isinstance(service_key, dict) and 'privateKeyData' in service_key): 313 | service_key_path = os.path.join(service_key_folder, f'{account_number}.json') 314 | if misc.dump_service_file(service_key_path, service_key): 315 | logger.info(f"Created service key for account {account_email!r}: {service_key_path}") 316 | else: 317 | logger.error(f"Created service key for account, but failed to dump it to: {service_key_path}") 318 | sys.exit(1) 319 | else: 320 | logger.error(f"Failed to create service key for account {account_email!r}:\n{service_key}\n") 321 | sys.exit(1) 322 | else: 323 | logger.error(f"Failed to create service account {account_name!r}:\n{service_account}\n") 324 | sys.exit(1) 325 | 326 | 327 | @app.command(help='Retrieve existing teamdrives') 328 | def list_teamdrives(): 329 | global google, cfg 330 | 331 | success, teamdrives = google.get_teamdrives() 332 | if success: 333 | logger.info(f'Existing teamdrives:\n{json.dumps(teamdrives, indent=2)}') 334 | sys.exit(0) 335 | else: 336 | logger.error(f'Failed to retrieve teamdrives:\n{teamdrives}') 337 | sys.exit(1) 338 | 339 | 340 | @app.command(help='Create teamdrive') 341 | @click.option('--name', '-n', required=True, help='Name of the new teamdrive') 342 | def create_teamdrive(name): 343 | global google, cfg 344 | 345 | success, teamdrive = google.create_teamdrive(name) 346 | if success: 347 | logger.info(f'Created teamdrive {name!r}:\n{teamdrive}') 348 | sys.exit(0) 349 | else: 350 | logger.error(f'Failed to create teamdrive {name!r}:\n{teamdrive}') 351 | sys.exit(1) 352 | 353 | 354 | @app.command(help='Set users for a teamdrive') 355 | @click.option('--name', '-n', required=True, help='Name of the existing teamdrive') 356 | @click.option('--key-prefix', '-k', required=True, help='Name prefix of service accounts') 357 | def set_teamdrive_users(name, key_prefix): 358 | global google, cfg 359 | 360 | # validate the service key folder exists 361 | service_key_folder = os.path.join(cfg.service_account_folder, key_prefix) 362 | if not os.path.exists(service_key_folder): 363 | logger.error(f"The service key folder did not exist at: {service_key_folder}") 364 | sys.exit(1) 365 | 366 | # retrieve service key users to share teamdrive access with 367 | service_key_users = misc.get_service_account_users(service_key_folder) 368 | if service_key_users is None: 369 | logger.error(f"Failed to determine the service key user(s) to share with teamdrive: {name}") 370 | sys.exit(1) 371 | 372 | # retrieve teamdrive id 373 | success, teamdrives = google.get_teamdrives() 374 | if not success: 375 | logger.error(f"Unable to retrieve existing teamdrives:\n{teamdrives}") 376 | sys.exit(1) 377 | 378 | teamdrive_id = misc.get_teamdrive_id(teamdrives, name) 379 | if not teamdrive_id: 380 | logger.error(f"Failed to determine teamdrive_id of teamdrive with name {name!r}") 381 | sys.exit(1) 382 | 383 | logger.info( 384 | f"Sharing access to {name!r} teamdrive for {len(service_key_users)} service key user(s): {service_key_users}") 385 | 386 | # share access to teamdrive 387 | for service_key_user in service_key_users: 388 | success, resp = google.set_teamdrive_share_user(teamdrive_id, service_key_user) 389 | if success: 390 | logger.info(f"Shared access to {name!r} teamdrive for user: {service_key_user}") 391 | else: 392 | logger.error(f"Failed sharing access to {name!r} teamdrive for user {service_key_user!r}:\n{resp}") 393 | sys.exit(1) 394 | sys.exit(0) 395 | 396 | 397 | @app.command(help='Lists users for a teamdrive') 398 | @click.option('--name', '-n', required=True, help='Name of the teamdrive') 399 | def list_teamdrive_users(name): 400 | global google, cfg 401 | 402 | # retrieve the teamdrive id 403 | success, teamdrives = google.get_teamdrives() 404 | if not success: 405 | logger.error(f"Unable to retrieve existing teamdrives:\n{teamdrives}") 406 | sys.exit(1) 407 | 408 | teamdrive_id = misc.get_teamdrive_id(teamdrives, name) 409 | if not teamdrive_id: 410 | logger.error(f"Failed to determine teamdrive_id of teamdrive with name {name!r}") 411 | sys.exit(1) 412 | 413 | # get permissions (users) on the teamdrive 414 | success, teamdrive_permissions = google.get_teamdrive_permissions(teamdrive_id) 415 | if not success: 416 | logger.error(f"Unable to retrieve existing permissions on teamdrive {name!r}:\n{teamdrive_permissions}") 417 | sys.exit(1) 418 | elif 'permissions' not in teamdrive_permissions: 419 | logger.error( 420 | f"Unexpected response when retrieving existing permission(s) on teamdrive {name!r}:\n" 421 | f"{teamdrive_permissions}") 422 | sys.exit(1) 423 | 424 | # remove permissions that are already deleted 425 | for permission in copy(teamdrive_permissions['permissions']): 426 | if permission['deleted']: 427 | # this permission is already deleted, lets remove it 428 | teamdrive_permissions['permissions'].remove(permission) 429 | 430 | logger.info(f"Existing users on teamdrive {name!r}:\n{json.dumps(teamdrive_permissions, indent=2)}") 431 | sys.exit(0) 432 | 433 | 434 | @app.command(help='Remove users from a teamdrive') 435 | @click.option('--name', '-n', required=True, help='Name of the teamdrive') 436 | @click.option('--email', '-e', required=False, default='ALL', show_default=True, help='Email of user to remove') 437 | @click.option('--keep-emails', '-k', required=False, multiple=True, help='Email of users to keep') 438 | @click.option('--service-accounts-only', '-sao', is_flag=True, required=False, default=False, show_default=True, 439 | help='Only remove service accounts') 440 | def remove_teamdrive_users(name, email, keep_emails, service_accounts_only): 441 | global google, cfg 442 | 443 | if email == 'ALL' and (not len(keep_emails) and not service_accounts_only): 444 | logger.error(f"You must specify an email to keep when removing access to all users (your teamdrive owner)") 445 | sys.exit(1) 446 | 447 | # retrieve the teamdrive id 448 | success, teamdrives = google.get_teamdrives() 449 | if not success: 450 | logger.error(f"Unable to retrieve existing teamdrives:\n{teamdrives}") 451 | sys.exit(1) 452 | 453 | teamdrive_id = misc.get_teamdrive_id(teamdrives, name) 454 | if not teamdrive_id: 455 | logger.error(f"Failed to determine teamdrive_id of teamdrive with name {name!r}") 456 | sys.exit(1) 457 | 458 | # get permissions (users) on the teamdrive 459 | success, teamdrive_permissions = google.get_teamdrive_permissions(teamdrive_id) 460 | if not success: 461 | logger.error(f"Unable to retrieve existing permissions on teamdrive {name!r}:\n{teamdrive_permissions}") 462 | sys.exit(1) 463 | elif 'permissions' not in teamdrive_permissions: 464 | logger.error( 465 | f"Unexpected response when retrieving existing permission(s) on teamdrive {name!r}:\n" 466 | f"{teamdrive_permissions}") 467 | sys.exit(1) 468 | 469 | # remove permissions that are already deleted 470 | for permission in copy(teamdrive_permissions['permissions']): 471 | if permission['deleted']: 472 | # this permission is already deleted, lets remove it 473 | teamdrive_permissions['permissions'].remove(permission) 474 | 475 | logger.info(f"Found {len(teamdrive_permissions['permissions'])} permissions for teamdrive {name!r}") 476 | 477 | # loop emails removing their access 478 | for permission in teamdrive_permissions['permissions']: 479 | # only go further (remove a user) if email was not supplied, or this permission email matches 480 | if email != 'ALL' and permission['emailAddress'].lower() != email.lower(): 481 | continue 482 | 483 | # is this in service accounts only mode 484 | if service_accounts_only and not permission['emailAddress'].lower().endswith('.iam.gserviceaccount.com'): 485 | continue 486 | 487 | # is this a safe email? 488 | if misc.is_safe_email(keep_emails, permission['emailAddress']): 489 | logger.info(f"Keeping permissions on teamdrive {name!r} for: {permission['emailAddress']}") 490 | continue 491 | 492 | success, resp = google.delete_teamdrive_share_user(teamdrive_id, permission['id']) 493 | if success: 494 | logger.info(f"Removed permissions on teamdrive {name!r} for: {permission['emailAddress']}") 495 | else: 496 | logger.error(f"Unexpected response when removing permissions on teamdrive {name!r} for " 497 | f"{permission['emailAddress']!r}:\n{resp}") 498 | sys.exit(1) 499 | sys.exit(0) 500 | 501 | 502 | ############################################################ 503 | # MAIN 504 | ############################################################ 505 | 506 | if __name__ == "__main__": 507 | app() 508 | --------------------------------------------------------------------------------