diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a14f4379..ee7c13ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -557,8 +557,7 @@ jobs: $gam oauth info $gam info domain $gam oauth refresh - $gam info user - #$gam info user $gam_user grouptree + $gam info use export tstamp=$($PYTHON -c "import time; print(time.time_ns())") export newbase=gha_test_$JID_$tstamp export newuser=$newbase@pdl.jaylee.us @@ -566,12 +565,14 @@ jobs: export newalias=$newbase-alias@pdl.jaylee.us export newbuilding=$newbase-building export newresource=$newbase-resource + export newou="/## Github Actions/${newbase}" + $gam create ou "${newou}" export GAM_THREADS=5 echo email > sample.csv; for i in {1..10}; do echo "${newbase}-bulkuser-$i" >> sample.csv; done - $gam create user $newuser firstname GHA lastname $JID password random recoveryphone 12125121110 recoveryemail jay0lee@gmail.com gha.jid $JID languages en+,en-GB- + $gam create user $newuser firstname GHA lastname $JID password random ou "${newou}" recoveryphone 12125121110 recoveryemail jay0lee@gmail.com gha.jid $JID languages en+,en-GB- $gam user $newuser update photo https://dummyimage.com/400x600/000/fff $gam user $newuser get photo $gam user $newuser delete photo @@ -585,7 +586,7 @@ jobs: $gam update group $newgroup add owner $gam_user $gam update group $newgroup add member $newuser $gam create admin $newuser _GROUPS_EDITOR_ROLE CUSTOMER # condition nonsecuritygroup - $gam csv sample.csv gam create user ~~email~~ firstname "GHA Bulk" lastname ~~email~~ gha.jid $JID + $gam csv sample.csv gam create user ~~email~~ firstname "GHA Bulk" lastname ~~email~~ gha.jid $JID ou "${newou}" $gam csv sample.csv gam update user ~~email~~ recoveryphone 12125121110 recoveryemail jay0lee@gmail.com password random $gam csv sample.csv gam update user ~~email~~ recoveryphone "" recoveryemail "" $gam csv sample.csv gam user ~email add license workspaceenterpriseplus @@ -678,11 +679,15 @@ jobs: driveid=$($gam user $gam_user add shareddrive "${newbase}" | awk '{print $NF}') echo "Created shared drive ${driveid}" $gam user $gam_user add drivefile localfile gam.py parentid "${driveid}" - $gam user $gam_user update shareddrive "${driveid}" ou "id:03ph8a2z1t2ph5z" + $gam user $gam_user update shareddrive "${driveid}" ou "${newou}" $gam user $gam_user show shareddrives asadmin $gam user $gam_user delete shareddrive "${driveid}" nukefromorbit echo "printer model count:" $gam print printermodels | wc -l + $gam create inboundssoprofile name "El Goog ${newbase}" signinurl https://www.google.com signouturl https://www.google.com changepasswordurl https://www.google.com entityid ElGoog + $gam create inboundssocredential profile "El Goog ${newbase}" generate_key + $gam create inboundssoassignment profile "El Goog ${newbase}" orgunit "${newou}" mode SAML_SSO + $gam delete ou "${newou}" #$gam print printers #$gam create printer displayname "${newbase}" uri ipp://localhost:631 driverless description "made by $(gam_user)" ou / #export CUSTOMER_ID="C01wfv983" diff --git a/src/gam/__init__.py b/src/gam/__init__.py index 60c27012..87fbbef8 100755 --- a/src/gam/__init__.py +++ b/src/gam/__init__.py @@ -65,6 +65,7 @@ from gam.gapi import chromemanagement as gapi_chromemanagement from gam.gapi import chromepolicy as gapi_chromepolicy from gam.gapi.cloudidentity import devices as gapi_cloudidentity_devices from gam.gapi.cloudidentity import groups as gapi_cloudidentity_groups +from gam.gapi.cloudidentity import inboundsso as gapi_cloudidentity_inboundsso from gam.gapi.cloudidentity import orgunits as gapi_cloudidentity_orgunits from gam.gapi.cloudidentity import userinvitations as gapi_cloudidentity_userinvitations from gam.gapi import contactdelegation as gapi_contactdelegation @@ -7726,7 +7727,7 @@ def doUpdateProjects(): _grantRotateRights(iam, sa_email, sa_email) -def _generatePrivateKeyAndPublicCert(client_id, key_size): +def _generatePrivateKeyAndPublicCert(client_id, key_size, b64enc_pub=True): print(' Generating new private key...') private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size, @@ -7770,6 +7771,8 @@ def _generatePrivateKeyAndPublicCert(client_id, key_size): backend=default_backend()) public_cert_pem = certificate.public_bytes( serialization.Encoding.PEM).decode() + if not b64enc_pub: + return private_pem, public_cert_pem publicKeyData = base64.b64encode(public_cert_pem.encode()) if isinstance(publicKeyData, bytes): publicKeyData = publicKeyData.decode() @@ -10589,6 +10592,11 @@ OAUTH2_SCOPES = [ 'subscopes': ['readonly'], 'scopes': 'https://www.googleapis.com/auth/cloud-identity.groups' }, + { + 'name': 'Cloud Identity - Inbound SSO Settings', + 'subscopes': ['readonly'], + 'scopes': 'https://www.googleapis.com/auth/cloud-identity.inboundsso', + }, { 'name': 'Cloud Identity - OrgUnits', 'subscopes': ['readonly'], @@ -11467,7 +11475,13 @@ def ProcessGAMCommand(args): gapi_cloudidentity_groups.create() elif argument in ['nickname', 'alias']: doCreateAlias() - elif argument in ['org', 'ou']: + elif argument in ['inboundssoprofile', 'inboundssoprofiles']: + gapi_cloudidentity_inboundsso.create_profile() + elif argument in ['inboundssocredential', 'inboundssocredentials']: + gapi_cloudidentity_inboundsso.create_credentials() + elif argument in ['inboundssoassignment', 'inboundssoassignments']: + gapi_cloudidentity_inboundsso.create_assignment() + elif argument in ['org', 'orgunit', 'ou']: gapi_directory_orgunits.create() elif argument == 'resource': gapi_directory_resource.createResourceCalendar() @@ -11539,10 +11553,14 @@ def ProcessGAMCommand(args): gapi_cloudidentity_groups.update() elif argument in ['nickname', 'alias']: doUpdateAlias() - elif argument in ['ou', 'org']: + elif argument in ['inboundssoassignment', 'inboundssoasignments']: + gapi_cloudidentity_inboundsso.update_assignment() + elif argument in ['ou', 'org', 'orgunit']: gapi_directory_orgunits.update() elif argument == 'resource': gapi_directory_resource.updateResourceCalendar() + elif argument in ['inboundssoprofile', 'inboundssoprofiles']: + gapi_cloudidentity_inboundsso.update_profile() elif argument == 'cros': gapi_directory_cros.doUpdateCros() elif argument == 'mobile': @@ -11604,7 +11622,9 @@ def ProcessGAMCommand(args): doGetAliasInfo() elif argument == 'instance': gapi_directory_customer.doGetCustomerInfo() - elif argument in ['org', 'ou']: + elif argument in ['inboundssoprofile', 'inboundssoprofiles']: + gapi_cloudidentity_inboundsso.info_profile() + elif argument in ['org', 'ou', 'orgunit']: gapi_directory_orgunits.info() elif argument == 'resource': gapi_directory_resource.getResourceCalendarInfo() @@ -11677,10 +11697,14 @@ def ProcessGAMCommand(args): gapi_cloudidentity_devices.delete_user() elif argument == 'cigroup': gapi_cloudidentity_groups.delete() + elif argument in ['inboundssoprofile', 'inboundssoprofiles']: + gapi_cloudidentity_inboundsso.delete_profile() elif argument in ['nickname', 'alias']: doDeleteAlias() elif argument == 'org': gapi_directory_orgunits.delete() + elif argument in ['inboundssocredential', 'inboundssocredentials']: + gapi_cloudidentity_inboundsso.delete_credentials() elif argument == 'resource': gapi_directory_resource.deleteResourceCalendar() elif argument == 'mobile': @@ -11770,8 +11794,14 @@ def ProcessGAMCommand(args): gapi_chromemanagement.printShowCrosTelemetry('print') elif argument in ['groupmembers', 'groupsmembers']: gapi_directory_groups.print_members() + elif argument in ['inboundssoassignment', 'inboundssoassignments']: + gapi_cloudidentity_inboundsso.print_assignments() elif argument in ['cigroupmembers', 'cigroupsmembers']: gapi_cloudidentity_groups.print_members() + elif argument in ['inboundssoprofile', 'inboundssoprofiles']: + gapi_cloudidentity_inboundsso.print_profiles() + elif argument in ['inboundssocredential', 'inboundssocredentials']: + gapi_cloudidentity_inboundsso.print_credentials() elif argument in ['orgs', 'ous']: gapi_directory_orgunits.print_() elif argument == 'privileges': diff --git a/src/gam/gapi/cloudidentity/groups.py b/src/gam/gapi/cloudidentity/groups.py index f3049e9e..3ef0739d 100644 --- a/src/gam/gapi/cloudidentity/groups.py +++ b/src/gam/gapi/cloudidentity/groups.py @@ -935,6 +935,12 @@ def group_email_to_id(ci, group, i=0, count=0): return None +def group_id_to_email(ci, group_id): + return gapi.call(ci.groups(), + 'get', + fields='groupKey/id', + name=group_id).get('groupKey', {}).get('id') + def membership_email_to_id(ci, parent, membership, i=0, count=0): membership = gam.normalizeEmailAddressOrUID(membership) try: diff --git a/src/gam/gapi/cloudidentity/inboundsso.py b/src/gam/gapi/cloudidentity/inboundsso.py new file mode 100644 index 00000000..a8101fe2 --- /dev/null +++ b/src/gam/gapi/cloudidentity/inboundsso.py @@ -0,0 +1,375 @@ +"""Methods related to Cloud Identity Inbound (Google as SP) SAML SSO""" +from datetime import datetime +import sys + +import dateutil.parser +import googleapiclient + +import gam +from gam.var import GC_CUSTOMER_ID, GC_Values, MY_CUSTOMER +from gam import controlflow +from gam import display +from gam import fileutils +from gam import gapi +from gam.gapi import errors as gapi_errors +from gam.gapi import cloudidentity as gapi_cloudidentity +from gam.gapi import directory as gapi_directory +from gam.gapi.cloudidentity import groups as gapi_cloudidentity_groups +from gam.gapi.directory import orgunits as gapi_directory_orgunits + +'''returns customer in the format inboundsso requires''' +def get_sso_customer(): + customer = GC_Values[GC_CUSTOMER_ID] + return f'customers/{customer}' + + +'''returns org unit in the format inboundsso requires''' +def get_orgunit_id(orgunit): + ou_id = gapi_directory_orgunits.getOrgUnitId(orgunit)[1] + if ou_id.startswith('id:'): + ou_id = ou_id[3:] + return f'orgUnits/{ou_id}' + + +'''build Cloud Identity API''' +def build(): + return gapi_cloudidentity.build('cloudidentity_beta') + + +'''parse cmd for profile create/update''' +def parse_profile(body, i): + while i < len(sys.argv): + myarg = sys.argv[i].lower().replace('_', '') + if myarg == 'name': + body['displayName'] = sys.argv[i+1] + i += 2 + elif myarg == 'entityid': + body.setdefault('idpConfig', {})['entityId'] = sys.argv[i+1] + i += 2 + elif myarg == 'loginurl': + body.setdefault('idpConfig', {})['singleSignOnServiceUri'] = sys.argv[i+1] + i += 2 + elif myarg == 'logouturl': + body.setdefault('idpConfig', {})['logoutRedirectUri'] = sys.argv[i+1] + i += 2 + elif myarg == 'changepasswordurl': + body.setdefault('idpConfig', {})['changePasswordUri'] = sys.argv[i+1] + i += 2 + else: + controlflow.invalid_argument_exit(myarg, 'gam create/update inboundssoprofile') + return body + + +'''convert profile nice names to unique ID''' +def profile_displayname_to_name(displayName, ci=None): + if displayName.lower().startswith('id:') or displayName.lower().startswith('uid:'): + displayName = displayName.split(':', 1)[1] + if not displayName.startswith('inboundSamlSsoProfiles/'): + displayName = f'inboundSamlSsoProfiles/{displayName}' + return displayName + if not ci: + ci = build() + customer = get_sso_customer() + _filter = f'customer=="{customer}"' + profiles = gapi.get_all_pages(ci.inboundSamlSsoProfiles(), + 'list', + 'inboundSamlSsoProfiles', + filter=_filter, + ) + matches = [] + for profile in profiles: + if displayName.lower() == profile.get('displayName', '').lower(): + matches.append(profile) + if len(matches) == 1: + return matches[0]['name'] + elif len(matches) == 0: + controlflow.system_error_exit(3, f'No Inbound SSO profile matching the name {displayName}') + else: + err_text = f'Multiple profiles matching {displayName}:\n\n' + for m in matches: + err_text += f' {m["name"]} {m["displayName"]}\n' + controlflow.system_error_exit(3, err_text) + + +'''gam create inboundssoprofile''' +def create_profile(): + ci = build() + body = { + 'customer': get_sso_customer(), + 'displayName': 'SSO Profile' + } + body = parse_profile(body, 3) + result = gapi.call(ci.inboundSamlSsoProfiles(), 'create', body=body) + display.print_json(result) + + +'''gam print inboundssoprofiles''' +def print_profiles(): + customer = get_sso_customer() + _filter = f'customer=="{customer}"' + ci = build() + profiles = gapi.get_all_pages(ci.inboundSamlSsoProfiles(), + 'list', + 'inboundSamlSsoProfiles', + filter=_filter, + ) + for profile in profiles: + display.print_json(profile) + print() + + +'''gam update inboundssoprofile''' +def update_profile(): + ci = build() + name = profile_displayname_to_name(sys.argv[3], ci) + body = {} + body = parse_profile(body, 4) + updateMask = ','.join(body.keys()) + result = gapi.call(ci.inboundSamlSsoProfiles(), + 'patch', + name=name, + updateMask=updateMask, + body=body) + display.print_json(result) + + +'''gam info inboundssoprofile''' +def info_profile(): + ci = build() + name = profile_displayname_to_name(sys.argv[3], ci) + result = gapi.call(ci.inboundSamlSsoProfiles(), + 'get', + name=name, + ) + display.print_json(result) + + +'''gam delete inboundssoprofile''' +def delete_profile(): + ci = build() + name = profile_displayname_to_name(sys.argv[3], ci) + result = gapi.call(ci.inboundSamlSsoProfiles(), + 'delete', + name=name) + if result.get('done'): + print(f' deleted profile {name}.') + else: + controlflow.system_error_exit(3, 'Delete did not finish: {result}') + + +'''gam create inboundssocredentials''' +def create_credentials(): + allowed_sizes = [1024, 2048, 4096] + ci = build() + parent = None + generate_key = False + key_size = 2048 + pemData = None + replace_oldest = False + i = 3 + while i < len(sys.argv): + myarg = sys.argv[i].lower().replace('_', '') + if myarg == 'profile': + parent = sys.argv[i+1] + parent = profile_displayname_to_name(parent, ci) + i += 2 + elif myarg == 'pemfile': + pemfile = sys.argv[i+1] + pemData = fileutils.read_file(pemfile) + i += 2 + elif myarg == 'generatekey': + generate_key = True + i += 1 + elif myarg == 'replaceoldest': + replace_oldest = True + i += 1 + elif myarg == 'keysize': + key_size = int(sys.argv[i+1]) + if key_size not in allowed_sizes: + controlflow.expected_argument_exit('key_size', + ALLOWED_KEY_SIZES, + key_size) + i += 2 + else: + controlflow.invalid_argument_exit(myarg, + 'gam create inboundssocredential') + if not parent: + controlflow.missing_argument_exit('profile', + 'gam create inboundssocredential') + if replace_oldest: + fields='nextPageToken,idpCredentials(name,updateTime)' + current_creds = gapi.get_all_pages( + ci.inboundSamlSsoProfiles().idpCredentials(), + 'list', + 'idpCredentials', + parent=parent, + fields=fields) + if len(current_creds) == 2: + oldest_key = min(current_creds, + key=lambda x:x['updateTime']) + print(' deleting older key...') + delete_credentials(ci=ci, + name=oldest_key['name']) + else: + print(' profile has {len(current_creds)} credentials. We only replace if there are 2.') + if generate_key: + privKey, pemData = gam._generatePrivateKeyAndPublicCert('GAM', + key_size, + b64enc_pub=False) + timestamp = datetime.now().strftime('%Y%m%d-%I%M%S') + priv_file = f'privatekey-{timestamp}.pem' + pub_file = f'publiccert-{timestamp}.pem' + fileutils.write_file(priv_file, privKey) + print(f' Wrote private key data to {priv_file}') + fileutils.write_file(pub_file, pemData) + print(f' Wrote public certificate to {pub_file}') + if not pemData: + controlflow.system_error_exit(3, 'You must either specify "pemfile " or "generate_key"') + body = { + 'pemData': pemData, + } + result = gapi.call(ci.inboundSamlSsoProfiles().idpCredentials(), + 'add', + parent=parent, + fields='done,response', + body=body) + if result.get('done'): + print(f'Created credential {result["response"]["name"]}') + else: + controlflow.system_error_exit(3, + 'Create did not finish {result}') + +def delete_credentials(ci=None, name=None): + if not ci: + ci = build() + if not name: + name = sys.argv[3] + result = gapi.call(ci.inboundSamlSsoProfiles().idpCredentials(), + 'delete', + name=name) + if result.get('done'): + print(f' deleted credential {name}') + else: + controlflow.system_error_exit(3, 'Delete did not finish {result}') + + +def print_credentials(): + ci = build() + i = 3 + profiles = [] + while i > len(sys.argv): + myarg = sys.argv[i].lower().replace('_', '') + if myarg in ['profile', 'profiles']: + profiles = sys.argv[i+1].split(',') + for profile in profiles: + profile = profile_displayname_to_name(profile, ci) + else: + controlflow.invalid_argument_exit(myarg, 'gam print inboundssocredentials') + if not profiles: + customer = get_sso_customer() + _filter = f'customer=="{customer}"' + profiles = gapi.get_all_pages(ci.inboundSamlSsoProfiles(), + 'list', + 'inboundSamlSsoProfiles', + fields='inboundSamlSsoProfiles/name', + filter=_filter, + ) + profiles = [p['name'] for p in profiles] + for profile in profiles: + credentials = gapi.get_all_pages(ci.inboundSamlSsoProfiles().idpCredentials(), + 'list', + 'idpCredentials', + parent=profile) + for c in credentials: + display.print_json(c) + print() + +def parse_assignment(body, i, ci): + while i < len(sys.argv): + myarg = sys.argv[i].lower().replace('_', '') + if myarg == 'rank': + body['rank'] = int(sys.argv[i+1]) + i += 2 + elif myarg == 'mode': + mode_choices = gapi.get_enum_values_minus_unspecified( + ci._rootDesc['schemas']['InboundSsoAssignment']['properties']['ssoMode']['enum']) + body['ssoMode'] = sys.argv[i+1].upper() + if body['ssoMode'] not in mode_choices: + controlflow.expected_argument_exit('mode', + ', '.join(mode_choices), + sys.argv[i+1]) + i += 2 + elif myarg == 'profile': + profile_name = profile_displayname_to_name( + sys.argv[i+1], + ci) + body['samlSsoInfo'] = { + 'inboundSamlSsoProfile': profile_name + } + i += 2 + elif myarg == 'neverredirect': + body['signInBehavior'] = { + 'redirectCondition': 'NEVER' + } + i += 1 + elif myarg == 'group': + group = sys.argv[i+1] + body['targetGroup'] = gapi_cloudidentity_groups.group_email_to_id( + ci, + group) + i += 2 + elif myarg in ['ou', 'orgunit']: + body['targetOrgUnit'] = get_orgunit_id(sys.argv[i+1]) + i += 2 + else: + controlflow.invalid_argument_exit(myarg, 'gam create/update inboundssoassignment') + return body + + +def create_assignment(): + ci = build() + body = { + 'customer': get_sso_customer(), + } + body = parse_assignment(body, 3, ci) + result = gapi.call(ci.inboundSsoAssignments(), + 'create', + body=body) + display.print_json(result) + + +def update_assignment(): + ci = build() + name = sys.argv[3] + body = {} + body = parse_assignment(body, 4, ci) + updateMask = ','.join(list(body.keys())) + result = gapi.call(ci.inboundSsoAssignments(), + 'patch', + name=name, + updateMask=updateMask, + body=body, + ) + display.print_json(result) + +def print_assignments(): + ci = build() + customer = get_sso_customer() + _filter = f'customer=="{customer}"' + assignments = gapi.get_all_pages(ci.inboundSsoAssignments(), + 'list', + 'inboundSsoAssignments', + filter=_filter, + ) + cd = gapi_directory.build() + for assignment in assignments: + if 'targetGroup' in assignment: + assignment['groupEmail'] = gapi_cloudidentity_groups.group_id_to_email(ci, assignment['targetGroup']) + if 'targetOrgUnit' in assignment: + ou_id = assignment['targetOrgUnit'] + ou_id = ou_id.split('/')[1] + ou_id = f'id:{ou_id}' + assignment['orgUnit'] = gapi_directory_orgunits.orgunit_from_orgunitid(ou_id, cd) + display.print_json(assignment) + print() +