Compare commits

...

136 Commits
v6.01 ... v6.07

Author SHA1 Message Date
Jay Lee
a3d560a8a2 YubiKey improvements and PIV reset 2021-07-27 09:24:34 -04:00
Jay Lee
ed20fe252e Use with conn so Yubikey connections close sooner 2021-07-26 14:46:58 -04:00
Jay Lee
375e36ff96 State what we don't like about invalid JSON 2021-07-26 14:45:26 -04:00
Jay Lee
e7108b108e Update build.yml 2021-07-23 13:31:06 -04:00
Jay Lee
6d59daad19 Update build.yml 2021-07-23 13:27:53 -04:00
Jay Lee
21c693921b Update build.yml 2021-07-23 13:13:34 -04:00
Jay Lee
7bcd5fbed7 Update build.yml 2021-07-23 13:06:22 -04:00
Jay Lee
7104970e17 Update build.yml 2021-07-23 13:01:20 -04:00
Jay Lee
1a2950b580 Update build.yml 2021-07-23 12:59:34 -04:00
Jay Lee
085b24e1c5 Update build.yml 2021-07-23 12:55:39 -04:00
Jay Lee
8688ce6328 Update build.yml 2021-07-23 12:52:35 -04:00
Jay Lee
fbdfed81e7 Update build.yml 2021-07-23 12:49:21 -04:00
Ross Scroggs
94fe20607e Updates for CRM v3 changes (#1401) 2021-07-22 19:19:10 -04:00
Ross Scroggs
6c62483e8e Updates for CRM v3 changes (#1400) 2021-07-21 17:27:46 -04:00
Ross Scroggs
54689129c6 Update gam print|show|update chromepolicy to handle the following special case policies: (#1399)
```
chrome.users.AutoUpdateCheckPeriodNew autoupdatecheckperiodminutesnew
chrome.users.BrowserSwitcherDelayDuration browserswitcherdelayduration
chrome.users.FetchKeepaliveDurationSecondsOnShutdown fetchkeepalivedurationsecondsonshutdown
chrome.users.MaxInvalidationFetchDelay maxinvalidationfetchdelay
chrome.users.PrintingMaxSheetsAllowed printingmaxsheetsallowednullable
chrome.users.PrintJobHistoryExpirationPeriodNew printjobhistoryexpirationperioddaysnew
chrome.users.SecurityTokenSessionSettings securitytokensessionnotificationseconds
chrome.users.SessionLength sessiondurationlimit
chrome.users.UpdatesSuppressed updatessuppresseddurationmin
chrome.users.UpdatesSuppressed updatessuppressedstarttime
```
2021-07-20 17:25:17 -04:00
Ross Scroggs
e9e8dd5a82 Fix call to be compatible with CRM v3 (#1398) 2021-07-19 19:31:08 -04:00
Jay Lee
00e764b118 Migrate to Resource Manager API v3 2021-07-16 10:14:58 -04:00
Jay Lee
cee7eb970a Merge branch 'main' of https://github.com/jay0lee/GAM 2021-07-13 10:45:21 -04:00
Jay Lee
daed17fac8 exclude null character, max out passwd length on random 2021-07-13 10:44:58 -04:00
Ross Scroggs
8708f4f93f Fix page_args_in_body, update namespace handling in show chromepolicies (#1393)
When page_args_in_body is true you have to add body to kwargs to ensure a place for pageToken

Allow setting a list of namespaces that override the defaults for printerid (not likely) and appid.
2021-07-08 08:55:18 -04:00
Jay Lee
c7c1bfbeba retry wait for mailbox if user doesn't exist 2021-07-07 11:03:04 -04:00
Jay Lee
0418438b6f increase rounds to Google max 2021-07-07 10:53:34 -04:00
Jay Lee
a2ea4d036e improve random password generator 2021-07-07 10:47:34 -04:00
Jay Lee
dc7a29908f updates to allow listing/setting extension policy 2021-07-02 13:36:21 -04:00
Jay Lee
794db5d2a4 More APIs now work with discovery v2 URL 2021-07-01 14:47:40 -04:00
Jay Lee
e5f9db129b Improve printing of app/extension/printer policy 2021-06-30 11:18:29 -04:00
Jay Lee
a6aecf4e9d undo version in exe 2021-06-29 11:22:33 -04:00
Jay Lee
b59bc4ec90 Merge branch 'main' of https://github.com/jay0lee/GAM 2021-06-29 11:04:17 -04:00
Jay Lee
41920f7865 add version info to Windows exe 2021-06-29 11:02:44 -04:00
Jay Lee
4630bf5681 Update var.py 2021-06-29 08:13:59 -04:00
Ross Scroggs
1c78ebd20e Add groupidfllert <String> to gam report <ActivityApplicationName> (#1390) 2021-06-28 21:34:50 -04:00
Jay Lee
80d17cfda3 Update windows-install.sh 2021-06-28 17:28:10 -04:00
Jay Lee
a154007927 Update windows-install.sh 2021-06-28 17:22:57 -04:00
Jay Lee
bd8274cc27 Update windows-install.sh 2021-06-28 17:13:42 -04:00
Jay Lee
fb08991c05 Update windows-before-install.sh 2021-06-28 17:06:47 -04:00
Jay Lee
7c1f06fdf7 Update build.yml 2021-06-28 16:57:11 -04:00
Jay Lee
93b38b9f95 Update build.yml 2021-06-28 16:44:40 -04:00
Jay Lee
7ffc97d301 Update build.yml 2021-06-28 16:40:43 -04:00
Jay Lee
280301f258 Update build.yml 2021-06-28 16:36:04 -04:00
Jay Lee
40daf38f80 Update build.yml 2021-06-28 16:32:41 -04:00
Jay Lee
d24925cd5f Update build.yml 2021-06-28 16:30:55 -04:00
Ross Scroggs
cd42d54b43 Fix typo, document new drive fields (#1389)
* Fix typo, document new drive fields

* Document new drive attribute
2021-06-28 15:40:22 -04:00
Jay Lee
53d8ecb6bc Update build.yml 2021-06-28 15:39:47 -04:00
Jay Lee
98e87d0297 Update build.yml 2021-06-28 15:38:04 -04:00
Jay Lee
400b4af769 Update macos-before-install.sh 2021-06-28 15:35:40 -04:00
Jay Lee
368701afb1 Update build.yml 2021-06-28 15:33:58 -04:00
Jay Lee
a501b89ecd resource key support 2021-06-25 16:52:14 -04:00
Jay Lee
91cddd72e5 Update build.yml 2021-06-17 14:54:09 -04:00
Jay Lee
8a1f0c9dbf GAM 6.06 2021-06-17 14:47:47 -04:00
Jay Lee
e3e5318b4f Update build.yml 2021-06-17 14:33:08 -04:00
Jay Lee
b060664c9f Update build.yml 2021-06-17 14:29:38 -04:00
Jay Lee
83fbf0e8ac Update build.yml 2021-06-17 14:23:13 -04:00
Jay Lee
537a926618 Update build.yml 2021-06-11 09:49:39 -04:00
Jay Lee
f791a59b1d GAM 6.05 2021-06-11 09:30:42 -04:00
Jay Lee
0b8e41f993 cleanup 2021-06-11 09:29:32 -04:00
Ross Scroggs
f540fa2a38 Unescape \r and \n in chatmessage text so multiline messages can be created from command line (#1387)
* Unescape \r and \n in chatmessage text so multiline messages can be created

* Bring gam report activity list up to date
2021-06-10 09:33:51 -04:00
Ross Scroggs
2d7bc2f34a Check that required arguments are present (#1386)
* Check that required arguments are present

* Correct chat message documentation
2021-06-07 15:56:09 -04:00
Jay Lee
c2dea0a4d7 add a few new user attribute types 2021-06-04 15:55:50 -04:00
Jay Lee
42cbfbf8ed Update macos-install.sh 2021-06-02 14:02:37 -04:00
Jay Lee
137e79b012 Update macos-install.sh 2021-06-02 13:49:34 -04:00
Jay Lee
5849ed3ecc Update build.yml 2021-06-02 10:04:30 -04:00
Ross Scroggs
d3dc1e1197 Add chat commands (#1384) 2021-05-27 21:07:55 -04:00
Jay Lee
c20f0bef44 GAM 6.04 2021-05-26 13:12:28 -04:00
Jay Lee
c572b6b182 fix win zip and MSI 2021-05-26 11:59:57 -04:00
Jay Lee
a1392dbf86 fix archive path 2021-05-26 10:53:06 -04:00
Jay Lee
4e719bab5e few build / install cleanups 2021-05-25 12:59:57 -04:00
Jay Lee
34b51ea64a GAM 6.03 2021-05-25 12:49:10 -04:00
Jay Lee
5a2a72f530 fix importlib_metadata on Python < 3.8 2021-05-25 12:27:52 -04:00
Jay Lee
2ea80c41ab retry a few more key operations 2021-05-25 11:49:44 -04:00
Jay Lee
6f987958e8 Print versions for more libraries 2021-05-25 10:39:14 -04:00
Jay Lee
ae4007aad5 re-enable legacy linux build 2021-05-25 10:09:47 -04:00
Jay Lee
c4401f8bd4 refine Chat calls 2021-05-25 09:32:02 -04:00
Jay Lee
0e7472de50 Initial Support for Google Chat API 2021-05-24 16:37:39 -04:00
Jay Lee
e998c78609 name artificat zip 2021-05-24 12:30:18 -04:00
Jay Lee
c30b92cd38 fix msi build 2021-05-24 11:23:45 -04:00
Jay Lee
2bf2d2aef7 try src/ 2021-05-24 11:07:39 -04:00
Jay Lee
cdc04b0803 fix gampath on win/mac 2021-05-24 10:48:42 -04:00
Jay Lee
5f5875acc1 fix distpath linux 2021-05-24 10:11:40 -04:00
Jay Lee
d306c5e0a3 try with extensions 2021-05-24 10:04:52 -04:00
Jay Lee
19a815cffe One file 2021-05-24 09:54:57 -04:00
Jay Lee
da0c559293 artifact wildcard attempt #3 2021-05-24 09:33:42 -04:00
Jay Lee
a2c91ef7b3 artifact wildcard attempt #2 2021-05-24 09:24:51 -04:00
Jay Lee
722b94ca32 no --universal2 argument yet 2021-05-24 09:00:18 -04:00
Jay Lee
299742fe03 reverting Actions changes for universal2 attempt 2021-05-24 08:56:25 -04:00
Ross Scroggs
3964cbf911 Add dynamic option to update cigroup (#1381)
Update print|show teamdrive documentation
2021-05-17 17:09:34 -04:00
Jay Lee
63e4947ad5 fix version 2021-05-12 08:36:10 -04:00
Jay Lee
e3cb13a414 youtoo 2021-05-11 14:46:24 -04:00
Jay Lee
01fec79d78 --universal2 2021-05-11 14:39:25 -04:00
Jay Lee
a7043a1359 disable rebuild of bootloader 2021-05-11 14:00:06 -04:00
Jay Lee
91a93ecd62 only set target arch on 32-bit Win 2021-05-11 13:20:40 -04:00
Jay Lee
c52fdf6395 preserve gam builds as Action artifact 2021-05-11 12:56:22 -04:00
Jay Lee
1d1dad4b30 switch to PyInstaller branch for Apple M1 support 2021-05-11 12:53:14 -04:00
Jay Lee
f07a57e478 no strip 2021-05-11 10:28:13 -04:00
Jay Lee
ebacd9b4b4 standardize pyinstaller arguments 2021-05-11 10:22:29 -04:00
Jay Lee
f010e59597 remove -F on Windows 2021-05-11 10:15:10 -04:00
Jay Lee
a184d7a8e0 list gampath 2021-05-11 09:15:16 -04:00
Jay Lee
807f54c549 wheel 2021-05-11 09:02:50 -04:00
Jay Lee
24684abc1d wheel 2021-05-11 08:56:17 -04:00
Jay Lee
1f1a49976c upgrade PyInstaller 2021-05-11 08:52:08 -04:00
Jay Lee
562fda3079 disable staticx for now 2021-05-10 09:30:53 -04:00
Jay Lee
05642f3c14 try w/o readlink 2021-05-10 09:06:53 -04:00
Jay Lee
251e2774aa no mkdir 2021-05-10 08:59:30 -04:00
Jay Lee
2089589d34 fix distpath 2021-05-10 08:53:19 -04:00
Jay Lee
c48b135c43 more debug decrypt 2021-05-10 08:44:45 -04:00
Jay Lee
70121a6ebf more output 2021-05-07 11:14:03 -04:00
Jay Lee
c23e53585a fix gam binary paths 2021-05-07 10:59:05 -04:00
Jay Lee
89e964163e fix dist paths 2021-05-07 10:55:42 -04:00
Jay Lee
0357774ba6 Test moving back to one-dir instead of one-file for PyInstaller 2021-05-07 10:48:36 -04:00
Ross Scroggs
93cf750249 Code cleanup; display role for group members (#1379)
* Code cleanup; display role for group members

* Standardize member and membertree output

Should dates be added to membergtree output?

* Use member_id to get subgroup, avoid call to convert email to id

* Only show role on top-level members

* Use v1beta1 for info user grouptree

* Update groups.py
2021-05-07 09:07:44 -04:00
Ross Scroggs
b712f7a344 Cloud Identity v1 only uses preferredMemberKey (#1378)
* Cloud Identity v1 only uses preferredMemberKey

* Document print labels

* Cleanup/bug fix info user grouptree; fix todrive for print labels

* Standardize write_csv_file call in print labels

* Use cloudidentity_beta for calls that process memberKey

* Code cleanup
2021-05-06 08:10:36 -04:00
Jay Lee
4159a5cbb8 test on Python 3.10-beta 2021-05-04 12:27:52 -04:00
Jay Lee
2e78a291d4 test on Python 3.10 2021-05-04 12:22:22 -04:00
Jay Lee
3f1705c2a5 test on Python 3.10 2021-05-04 12:20:45 -04:00
Jay Lee
bb1f5f7059 Merge branch 'main' of https://github.com/jay0lee/GAM into main 2021-05-04 11:57:33 -04:00
Jay Lee
75b7d0c419 Mmebership oops 2021-05-04 11:57:19 -04:00
Ross Scroggs
41a6c11c55 Handle TYPE_MESSAGE fields with durations or counts as a special case (#1375)
* Handle TYPE_MESSAGE fields with durations or counts as a special case

* Allow schema TYPE_ENUM field values with/without common prefix
2021-05-04 10:15:36 -04:00
Jay Lee
57d908e369 disable grouptree for now 2021-05-04 09:59:21 -04:00
Jay Lee
64274fdb33 more test fixes 2021-05-04 09:19:59 -04:00
Jay Lee
da919fd189 add few tests and fix one 2021-05-04 09:17:14 -04:00
Jay Lee
cfa25f12d3 6.02, admin.googleapis.com as test, MacOS universal2 build 2021-05-04 09:12:45 -04:00
Jay Lee
05bc1c1263 just stick with staic python versions 2021-05-04 08:38:56 -04:00
Jay Lee
939c79c37f use env variable 2021-05-04 08:32:06 -04:00
Jay Lee
d352ddeea1 use env variable 2021-05-04 08:30:56 -04:00
Jay Lee
72a683f2b1 Merge branches (#1377)
* Fix tests with apiclient >= 2.1

* disable MacOS 11 job

* info user grouptree and info cigroup membertree

* build updates
2021-05-04 08:12:35 -04:00
Ross Scroggs
784399f345 Handle TYPE_MESSAGE fields with durations or counts as a special case (#1374) 2021-05-02 08:22:29 -04:00
Ross Scroggs
710be4371b Fix typo (#1373) 2021-04-30 21:00:51 -04:00
Jay Lee
eece358aec Googleapiclient test fix (#1372)
* Fix tests with apiclient >= 2.1

* disable MacOS 11 job
2021-04-26 07:35:07 -04:00
Ross Scroggs
b43ada4f83 Add Cloud Search product name (#1370) 2021-04-23 09:02:15 -04:00
Jay Lee
9030af4faf Cloud Search SKU 2021-04-21 09:46:11 -04:00
Ross Scroggs
38b424b62e Add convertalias to delegate commands to convert aliases to primary (#1368)
* Add convertalias to delegate commands to convert aliases to primary

* New PyInstaller, won't build ARM without it
2021-04-21 09:38:07 -04:00
Jay Lee
1d9bf0b1aa Merge branch 'main' of https://github.com/jay0lee/GAM into main 2021-04-20 15:57:06 -04:00
Jay Lee
d3b7700c07 re-enable MacOS universal2 build 2021-04-20 15:56:27 -04:00
Ross Scroggs
d9513e159f Added support for localfile - in gam <UserTypeEntity> create|update drivefile (#1366)
This allows commands/programs to output data to stdout which can then be uploaded to a Google Drive file.
```
generatedata | gam user user@domain.com create drivefile drivefilename test.csv localfile - mimetype gsheet
```
2021-04-20 15:43:09 -04:00
Jay Lee
6ddfdf2514 print labels with counts 2021-04-20 15:37:21 -04:00
Jay Lee
478804bd5c Show/print full teamdrives info 2021-04-15 12:25:04 -04:00
Jay Lee
b61165a753 make sure we are using primary addresses for delegation 2021-04-10 21:28:19 -04:00
26 changed files with 1081 additions and 437 deletions

View File

@@ -13,4 +13,5 @@ fi
gpg --quiet --batch --yes --decrypt --passphrase="${PASSCODE}" \
--output "${credsfile}" "${gpgfile}"
tar xf "${credsfile}" --directory "${gampath}"
tar xvvf "${credsfile}" --directory "${gampath}"
ls -l "${gampath}"

View File

@@ -1,8 +1,10 @@
export gampath="dist/gam"
export distpath="dist/"
export gampath="${distpath}gam"
rm -rf $gampath
mkdir -p $gampath
export gampath=$(readlink -e $gampath)
$python -OO -m PyInstaller --clean --noupx --strip -F --distpath $gampath gam.spec
#mkdir -p $gampath
#export gampath=$(readlink -e $gampath)
$pip install wheel
$python -OO -m PyInstaller --clean --noupx --strip --distpath $gampath gam.spec
export gam="${gampath}/gam"
export GAMVERSION=`$gam version simple`
cp LICENSE $gampath
@@ -11,7 +13,7 @@ this_glibc_ver=$(ldd --version | awk '/ldd/{print $NF}')
GAM_ARCHIVE="gam-${GAMVERSION}-${GAMOS}-${PLATFORM}-glibc${this_glibc_ver}.tar.xz"
rm $gampath/lastupdatecheck.txt
# tar will cd to dist and tar up gam/
tar -C dist/ --create --file $GAM_ARCHIVE --xz gam
tar -C ${distpath} --create --file $GAM_ARCHIVE --xz gam
echo "PyInstaller GAM info:"
du -h $gam
time $gam version extended

View File

@@ -22,11 +22,7 @@ cd ~
# Use official Python.org version of Python which is backwards compatible
# with older MacOS versions
if [ "$PLATFORM" == "x86_64" ]; then
export pyfile=python-$BUILD_PYTHON_VERSION-macosx10.9.pkg
else
export pyfile=python-$BUILD_PYTHON_VERSION-macos11.0.pkg
fi
export pyfile=python-$BUILD_PYTHON_VERSION-macos11.pkg
wget https://www.python.org/ftp/python/$BUILD_PYTHON_VERSION/$pyfile
echo "installing Python $BUILD_PYTHON_VERSION..."

View File

@@ -1,22 +1,19 @@
echo "MacOS Version Info According to Python:"
python -c "import platform; print(platform.mac_ver())"
echo "Xcode versionn:"
macver=$(python -c "import platform; print(platform.mac_ver()[0])")
echo $macver
echo "Xcode version:"
xcodebuild -version
export gampath=dist/gam
export distpath="dist/"
export gampath="${distpath}gam"
rm -rf $gampath
if [ "$PLATFORM" == "x86_64" ]; then
export specfile="gam.spec"
else
export specfile="gam-universal2.spec"
fi
$python -OO -m PyInstaller --clean --noupx --strip -F --distpath "${gampath}" "${specfile}"
export specfile="gam.spec"
$python -OO -m PyInstaller --clean --noupx --strip --distpath "${gampath}" --target-architecture $PLATFORM "${specfile}"
export gam="${gampath}/gam"
$gam version extended
export GAMVERSION=`$gam version simple`
cp LICENSE "${gampath}"
cp GamCommands.txt "${gampath}"
MACOSVERSION=$(defaults read loginwindow SystemVersionStampAsString)
GAM_ARCHIVE="gam-${GAMVERSION}-${GAMOS}-${PLATFORM}-MacOS${MACOSVERSION}.tar.xz"
GAM_ARCHIVE="gam-${GAMVERSION}-${GAMOS}-${PLATFORM}.tar.xz"
rm "${gampath}/lastupdatecheck.txt"
# tar will cd to dist/ and tar up gam/
tar -C dist/ --create --file $GAM_ARCHIVE --xz gam

View File

@@ -13,8 +13,8 @@ echo "This is a ${BITS}-bit build for ${PLATFORM}"
export mypath=$(pwd)
cd ~
export python="python"
export pip="pip"
export python="c:\python\python.exe"
export pip="c:\python\scripts\pip.exe"
# pyscard needs swig, keep these two together
choco install $CHOCOPTIONS swig

View File

@@ -4,11 +4,10 @@ elif [[ "$PLATFORM" == "x86" ]]; then
export WIX_BITS="x86"
fi
echo "compiling GAM with pyinstaller..."
export gampath="dist/gam"
export distpath="dist/"
export gampath="${distpath}gam"
rm -rf $gampath
mkdir -p $gampath
export gampath=$(readlink -e $gampath)
pyinstaller --clean --noupx -F --distpath $gampath gam.spec
/c/python/scripts/pyinstaller --clean --noupx --distpath $gampath gam.spec
export gam="${gampath}/gam"
echo "running compiled GAM..."
$gam version
@@ -18,8 +17,11 @@ cp LICENSE $gampath
cp GamCommands.txt $gampath
cp gam-setup.bat $gampath
GAM_ARCHIVE=gam-$GAMVERSION-$GAMOS-$PLATFORM.zip
/c/Program\ Files/7-Zip/7z.exe a -tzip $GAM_ARCHIVE $gampath -xr!.svn
cwd=$(pwd)
cd "${distpath}"
/c/Program\ Files/7-Zip/7z.exe a -tzip $GAM_ARCHIVE gam -xr!.svn
mv "${GAM_ARCHIVE}" "${cwd}"
cd "${cwd}"
echo "Running WIX candle $WIX_BITS..."
/c/Program\ Files\ \(x86\)/WiX\ Toolset\ v3.11/bin/candle.exe -arch $WIX_BITS gam.wxs
echo "Done with WIX candle..."

View File

@@ -12,13 +12,13 @@ defaults:
working-directory: src
env:
BUILD_PYTHON_VERSION: "3.9.4"
MIN_PYTHON_VERSION: "3.9.4"
BUILD_PYTHON_VERSION: "3.9.6"
MIN_PYTHON_VERSION: "3.9.6"
BUILD_OPENSSL_VERSION: "1.1.1k"
MIN_OPENSSL_VERSION: "1.1.1i"
MIN_OPENSSL_VERSION: "1.1.1k"
PATCHELF_VERSION: "0.12"
# PYINSTALLER_VERSION can be full commit hash or version like v4.20
PYINSTALLER_VERSION: "227eac14955c02db21d4702429896d4b74beed5e"
PYINSTALLER_VERSION: "0f2b2e921433ab5a510c7efdb21d9c1d7cfbc645"
jobs:
build:
@@ -41,31 +41,15 @@ jobs:
goal: "build"
gamos: "linux"
platform: "x86_64"
# - os: [self-hosted, linux, ARM]
# jid: 10
# goal: "build"
# gamos: "linux"
# platform: "arm"
# - os: [self-hosted, linux, ARM64]
# jid: 11
# goal: "build"
# gamos: "linux"
# platform: "arm64"
- os: macos-10.15
jid: 4
- os: macos-11.0
jid: 12
goal: "build"
gamos: "macos"
platform: "x86_64"
# - os: macos-11.0
# jid: 12
# goal: "build"
# gamos: "macos"
# platform: "universal2"
platform: "universal2"
- os: windows-2019
jid: 5
goal: "build"
gamos: "windows"
python: 3.9.4
pyarch: "x64"
platform: "x86_64"
- os: windows-2019
@@ -73,7 +57,6 @@ jobs:
goal: "build"
gamos: "windows"
platform: "x86"
python: 3.9.4
pyarch: "x86"
- os: ubuntu-20.04
goal: "test"
@@ -93,6 +76,12 @@ jobs:
jid: 9
gamos: "linux"
platform: "x86_64"
- os: ubuntu-20.04
goal: test
python: "3.10.0-beta.1"
jid: 10
gamos: linux
platform: x86_64
steps:
@@ -108,7 +97,7 @@ jobs:
path: |
~/python
~/ssl
key: ${{ matrix.os }}-${{ matrix.jid }}-20210407
key: ${{ matrix.os }}-${{ matrix.jid }}-20210628
- name: Set env variables
env:
@@ -123,13 +112,33 @@ jobs:
echo "PLATFORM=${PLATFORM}" >> $GITHUB_ENV
uname -a
- name: Use pre-compiled Python for testing and Windows
- name: Use pre-compiled Python for testing
if: matrix.python != ''
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
architecture: ${{ matrix.pyarch }}
- name: Install Python on Windows
if: matrix.os == 'windows-2019'
run: |
if ( ${Env:PLATFORM} -eq "x86_64" )
{
Set-Variable -name py_arch -value "-amd64"
}
else
{
Set-Variable -name py_arch -value ""
}
Write-Output "py_arch: $py_arch"
Set-Variable -name python_file -value "python-${Env:BUILD_PYTHON_VERSION}${py_arch}.exe"
Write-Output "python_file: $python_file"
Set-Variable -name python_url -value "https://www.python.org/ftp/python/${Env:BUILD_PYTHON_VERSION}/${python_file}"
Write-Output "python_url: $python_url"
Invoke-WebRequest -Uri $python_url -OutFile $python_file
Start-Process -wait -FilePath $python_file -ArgumentList "/quiet","InstallAllUsers=0","TargetDir=c:\\python","AssociateFiles=1","PrependPath=1"
shell: pwsh
- name: Set env variables for pre-compiled Python
if: matrix.goal == 'test'
run: |
@@ -145,6 +154,7 @@ jobs:
echo "RUNNING: apt update..."
sudo apt-get -qq --yes update > /dev/null
sudo apt-get -qq --yes install swig libpcsclite-dev
$pip install --upgrade pip
- name: Build and install Python, OpenSSL and PyInstaller
if: matrix.goal != 'test' && steps.cache-primes.outputs.cache-hit != 'true'
@@ -156,18 +166,24 @@ jobs:
echo "pip=$pip" >> $GITHUB_ENV
echo "LD_LIBRARY_PATH=$LD_LIBRARY_PATH" >> $GITHUB_ENV
echo -e "Python: $python\nPip: $pip\nLD_LIB...: $LD_LIBRARY_PATH"
$pip install wheel
export url="https://codeload.github.com/pyinstaller/pyinstaller/tar.gz/${PYINSTALLER_VERSION}"
echo "Downloading ${url}"
curl -o pyinstaller.tar.gz --compressed "${url}"
tar xf pyinstaller.tar.gz
cd "pyinstaller-${PYINSTALLER_VERSION}/bootloader"
if [ "${PLATFORM}" == "x86" ]; then
BITS="32"
else
BITS="64"
cd "pyinstaller-${PYINSTALLER_VERSION}/"
if [ $GAMOS == "windows" ]; then
# remove pre-compiled bootloaders so we fail if bootloader compile fails
rm -rf PyInstaller/bootloader/*bit
cd bootloader
if [ "${PLATFORM}" == "x86" ]; then
TARGETARCH="--target-arch=32bit"
else
TARGETARCH=""
fi
$python ./waf all $TARGETARCH
cd ..
fi
$python ./waf all --target-arch=${BITS}bit
cd ..
$python setup.py install
#$pip install pyinstaller
@@ -184,6 +200,7 @@ jobs:
run: |
set +e
source ../.github/actions/${GAMOS}-install.sh
ls -alRF $gampath
echo "gampath=$gampath" >> $GITHUB_ENV
echo "gam=$gam" >> $GITHUB_ENV
echo -e "GAM: ${gam}\nGAMPATH: ${gampath}\nGAMVERSION: ${GAMVERSION}"
@@ -225,6 +242,7 @@ jobs:
$gam info domain
$gam oauth refresh
$gam info user
#$gam info user $gam_user grouptree
export tstamp=$(date +%s%3N)
export newbase=gha-test-$JID-$tstamp
export newuser=$newbase@pdl.jaylee.us
@@ -251,6 +269,7 @@ jobs:
$gam csv sample.csv gam user $gam_user sendemail recipient ~~email~~@pdl.jaylee.us subject "test message $newbase" message "GHA test message"
$gam csv sample.csv gam update group $newgroup add member ~email
$gam info group $newgroup
$gam info cigroup $newgroup membertree
$gam user $gam_user check serviceaccount
# confirm mailbox is provisoned before continuing
$gam user $newuser waitformailbox
@@ -322,7 +341,7 @@ jobs:
$gam report admin start -3d todrive
$gam print devices nopersonaldevices nodeviceusers filter "serial:$JID$JID$JID$JID-" | $gam csv - gam delete device id ~name
$gam print userinvitations
$gam print userinvitations | $gam csv - gam create userinvitation ~name
$gam print userinvitations | $gam csv - gam send userinvitation ~name
export CUSTOMER_ID="C01wfv983"
export GA_DOMAIN="pdl.jaylee.us"
touch $gampath/enabledasa.txt
@@ -342,3 +361,13 @@ jobs:
echo "file uploaded as ${fileid}, setting ACL..."
$gam user $gam_user add drivefileacl $fileid anyone role reader withlink
done
- name: Archive production artifacts
uses: actions/upload-artifact@v2
if: github.event_name == 'push' && matrix.goal != 'test'
with:
name: gam-binaries
path: |
src/*.tar.xz
src/*.zip
src/*.msi

View File

@@ -158,6 +158,7 @@ If an item contains spaces, it should be surrounded by ".
<CalendarColorIndex> ::= <Number in range 1-24>
<CalendarItem> ::= <EmailAddress>|<String>
<ChatRoom> ::= <String>
<ChatSpace> ::= <String>
<ClientID> ::= <String>
<ColorValue> ::= <ColorName>|<ColorHex>
<CollaboratorItem> ::= <EmailAddress>|<UniqueID>|<String>
@@ -203,6 +204,7 @@ If an item contains spaces, it should be surrounded by ".
<MaximumNumberOfSeats> ::= <Number>
<MobileID> ::= <String>
<Name> ::= <String>
<Namespace> ::= <String>
<NotificationID> ::= <String>
<NumberOfSeats> ::= <Number>
<OrgUnitID> ::= <String>
@@ -223,6 +225,7 @@ If an item contains spaces, it should be surrounded by ".
<QueryGmail> ::= <String> See: https://support.google.com/mail/answer/7190
<QueryGroup> ::= <String> See: https://developers.google.com/admin-sdk/directory/v1/guides/search-groups
<QueryMobile> ::= <String> See: https://support.google.com/a/answer/7549103
<QueryTeamDrive> ::= <String> See: https://developers.google.com/drive/api/v3/search-shareddrives
<QueryUser> ::= <String> See: https://developers.google.com/admin-sdk/directory/v1/guides/search-users
<QueryVaultCorpus> ::= <String> See: https://developers.google.com/vault/reference/rest/v1/matters.holds#CorpusQuery
<RequestID> ::= <String>
@@ -331,6 +334,7 @@ If an item contains spaces, it should be surrounded by ".
lastmodifyinguser|
lastmodifyingusername|
lastviewedbyme|lastviewedbymedate|lastviewedbymetime|lastviewedbyuser|
linksharemetadata|
md5|md5checksum|md5sum|
mime|mimetype|
modifiedbyme|modifiedbymedate|modifiedbymetime|modifiedbyuser|
@@ -343,6 +347,7 @@ If an item contains spaces, it should be surrounded by ".
parents|
permissions|
quotabytesused|quotaused|
resourcekey|
restricted|
shareable|
shared|
@@ -590,6 +595,7 @@ Items, separated by spaces, with spaces, commas or single quotes in the items th
<MatterItemList> ::= "<MatterItem>(,<MatterItem>)*"
<MembersFieldNameList> ::= "<MembersFieldName>(,<MembersFieldName>)*"
<MobileList> ::= "<MobileId>(,<MobileId>)*"
<NamespaceList> ::= "<Namespace>(,<Namespace)*"
<OrgUnitList> ::= "<OrgUnitPath>(,<OrgUnitPath>)*"
<PrinterIDList> ::= "<PrinterID>)(,<PrinterID>)*"
<ProductIDList> ::= "(<ProductID>|SKUID>)(,<ProductID>|SKUID>)*"
@@ -685,7 +691,7 @@ Specify a collection of Users by directly specifying them or by specifiying item
(name <String>)
<DriveFileAddAttribute> ::=
(localfile <FileName>)|
(localfile <FileName>|-)|
(convert)|(ocr)|(ocrlanguage <Language>)|
(restricted|restrict)|(starred|star)|(trashed|trash)|(viewed|view)|
(contentrestrictions readonly false)|
@@ -693,9 +699,10 @@ Specify a collection of Users by directly specifying them or by specifiying item
copyrequireswriterpermission|
(lastviewedbyme <Time>)|(modifieddate|modifiedtime <Time>)|(description <String>)|(mimetype <MimeType>)|
(parentid <DriveFolderID>)|(parentname <DriveFolderName>)|(anyownerparentname <DriveFolderName>)|writerscantshare|writerscanshare
(securityupdate <Boolean>)|
(shortcut <DriveFileID>)
<DriveFileUpdateAttribute> ::=
(localfile <FileName>)|
(localfile <FileName>|-)|
(convert)|(ocr)|(ocrlanguage <Language>)|
(restricted|restrict <Boolean>)|(starred|star <Boolean>)|(trashed|trash <Boolean>)|(viewed|view <Boolean>)|
(contentrestrictions readonly false)|
@@ -703,6 +710,7 @@ Specify a collection of Users by directly specifying them or by specifiying item
(copyrequireswriterpermission <Boolean>)|
(lastviewedbyme <Time>)|(modifieddate <Time>)|(description <String>)|(mimetype <MimeType>)|
(parentid <DriveFolderID>)|(parentname <DriveFolderName>)|(anyownerparentname <DriveFolderName>)|writerscantshare|writerscanshare
(securityupdate <Boolean>)|
(shortcut <DriveFileID>)
<GroupSettingsAttribute> ::=
(allowexternalmembers <Boolean>)|
@@ -841,6 +849,8 @@ An argument containing instances of ~~xxx~~ has xxx replaced by the value of fie
Example: gam csv Users.csv gam update user "~primaryEmail" address type work unstructured "~~Street~~, ~~City~~, ~~State~~ ~~ZIP~~"
Each user (~primaryEmail, e.g. foo@bar.com) would have their work address updated
gam create gcpfolder <String>
gam create project [<EmailAddress>] [<ProjectID>]
gam create project [admin <EmailAddress>] [project <ProjectID>] [parent <String>]
gam use project [<EmailAddress>] [<ProjectID>]
@@ -894,23 +904,27 @@ gam delete resoldsubscription <CustomerID> <SKUID> cancel|downgrade|transfer_to_
gam info resoldsubscriptions <CustomerID> [customer_auth_token <String>]
<ActivityApplicationName> ::=
access|accesstransparency|
access_transparency|
admin|
calendar|calendars|
calendar|
chat|
drive|doc|docs|
enterprisegroups|groupsenterprise|
chrome|
context_aware_access|
data_studio|
drive|
gcp|
google+|gplus|
group|groups|
hangoutsmeet|meet|
gplus|
groups|
groups_enterprise|
jamboard|
login|logins|
keep|
login|
meet|
mobile|
oauthtoken|token|tokens|
rules|
saml|
useraccounts
token|
user_accounts
<ReportsApp> ::=
accounts|
@@ -949,6 +963,7 @@ gam report <ActivityApplicationName> [todrive]
[(user all|<UserItem>)|(orgunit|org|ou <OrgUnitPath>)]
[start <Time>] [end <Time>]
[filter|filters <String>] [event <String>] [ip <String>]
[groupidfilter <String>]
gam create admin <UserItem> <RoleItem> customer|(org_unit <OrgUnitItem>)
gam delete admin <RoleAssignmentId>
@@ -1166,6 +1181,14 @@ gam print browsertokens [todrive]
[fields <BrowserTokenFieldNameList>]
[sortheaders]
gam print chatspaces [todrive]
gam print chatmembers space <ChatSpace> [todrive]
gam create chatmessage space <ChatSpace> [thread <String>]
(text <String>)|(textfile <FileName> [charset <CharSet>])
gam delete chatmessage name <String>
gam update chatmessage name <String>
(text <String>)|(textfile <FileName> [charset <CharSet>])
<CrOSAction> ::=
deprovision_same_model_replace|
deprovision_different_model_replace|
@@ -1294,7 +1317,7 @@ gam print chromehistory releases [todrive]
gam delete chromepolicy <SchemaName>+ ou|org|orgunit <OrgUnitItem> [(printerid <PrinterID>)|(appid <AppID>)]
gam update chromepolicy (<SchemaName> (<Field> <Value>)+)+ ou|org|orgunit <OrgUnitItem> [(printerid <PrinterID>)|(appid <AppID>)]
gam show chromepolicy ou|org|orgunit <OrgUnitItem> [(printerid <PrinterID>)|(appid <AppID>)]
gam show chromepolicy ou|org|orgunit <OrgUnitItem> [(printerid <PrinterID>)|(appid <AppID>)] [namespace <NamespaceList>]
gam show chromeschema [filter <String>]
<DeviceID> ::= devices/<String>
@@ -1365,7 +1388,7 @@ gam update cigroup <GroupItem> sync [owner|manager|member] [notsuspended|suspend
gam update cigroup <GroupItem> update [owner|manager|member] [notsuspended|suspended] [expires never|<Time>] <UserTypeEntity>
gam update cigroup <GroupItem> clear [member] [manager] [owner] [notsuspended|suspended]
gam delete cigroup <GroupItem>
gam info cigroup <GroupItem> [nousers] [nojoindate] [showupdatedate]
gam info cigroup <GroupItem> [nousers] [nojoindate] [showupdatedate] [membertree]
gam print cigroups [todrive]
[enterprisemember <UserItem>]
@@ -1438,7 +1461,7 @@ gam create user <EmailAddress> <UserAttribute>* [verifynotinvitable]
gam update user <UserItem> <UserAttribute>* [clearschema <SchemaName>] [clearschema <SchemaName>.<FieldName>] [verifynotinvitable]
gam delete user <UserItem>
gam undelete user <UserItem> [org|ou <OrgUnitPath>]
gam info user [<UserItem>] [noaliases] [nogroups] [nolicenses|nolicences] [noschemas] [schemas|custom <SchemaNameList>] [userview] [skus|sku <SKUIDList>]
gam info user [<UserItem>] [noaliases] [nogroups] [nolicenses|nolicences] [noschemas] [schemas|custom <SchemaNameList>] [userview] [skus|sku <SKUIDList>] [grouptree]
Print fields for selected users; use domain, query/queries and deleted_only to select users to print;
if none of these options are specified, all users are printed.
@@ -1600,6 +1623,7 @@ gam <UserTypeEntity> update labelsettings <LabelName> [name <Name>] [messagelist
gam <UserTypeEntity> update label|labels [search <RegularExpression>] [replace <LabelReplacement>] [merge]
gam <UserTypeEntity> delete|del label|labels <LabelName>|regex:<RegularExpression>|--ALL_LABELS--
gam <UserTypeEntity> show labels|label [onlyuser] [showcounts]
gam <UserTypeEntity> print labels|label [todrive] [onlyuser] [showcounts]
gam <UserTypeEntity> delete messages query <QueryGmail> [doit] [max_to_delete|max_to_process <Number>]
gam <UserTypeEntity> modify messages query <QueryGmail> [doit] [max_to_modify|max_to_process <Number>] (addlabel <LabelName>)* (removelabel <LabelName>)*
@@ -1623,9 +1647,9 @@ gam <UserTypeEntity> sendemail [recipient|to <EmailAddress>] [from <EmailAddress
[subject <String>] [(message <String>)|(file <FileName> [charset <Charset>])]
(header <String> <String>)*
gam <UserTypeEntity> create|add delegate|delegates <EmailAddress>
gam <UserTypeEntity> delegate|delegates to <EmailAddress>
gam <UserTypeEntity> delete|del delegate|delegates <EmailAddress>
gam <UserTypeEntity> create|add delegate|delegates [convertalias] <EmailAddress>
gam <UserTypeEntity> delegate|delegates to [convertalias] <EmailAddress>
gam <UserTypeEntity> delete|del delegate|delegates [convertalias] <EmailAddress>
gam <UserTypeEntity> show delegates|delegate [csv]
gam <UserTypeEntity> print delegates [todrive]
@@ -1690,8 +1714,8 @@ gam <UserTypeEntity> update teamdrive <TeamDriveID> [asadmin] [name <Name>]
(<TeamDriveRestrictionsSubfieldName> <Boolean>)*
gam <UserTypeEntity> delete teamdrive <TeamDriveID>
gam <UserTypeEntity> show teamdriveinfo <TeamDriveID> [asadmin]
gam <UserTypeEntity> show teamdrives [asadmin]
gam <UserTypeEntity> print teamdrives [todrive] [asadmin]
gam <UserTypeEntity> show teamdrives [query <QueryTeamDrive>] [asadmin]
gam <UserTypeEntity> print teamdrives [query <QueryTeamDrive>] [todrive] [asadmin]
gam <UserTypeEntity> show teamdrivethemes
gam <UserTypeEntity> vacation <FalseValues>

View File

@@ -29,7 +29,7 @@ gamversion="latest"
adminuser=""
regularuser=""
gam_glibc_vers="2.31 2.27 2.23"
gam_macos_vers="10.15.6 10.14.6 10.13.6"
#gam_macos_vers="10.15.6 10.14.6 10.13.6"
while getopts "hd:a:o:b:lp:u:r:v:" OPTION
do
@@ -128,18 +128,6 @@ case $gamos in
this_macos_ver=$osversion
fi
echo "You are running MacOS $this_macos_ver"
use_macos_ver=""
for gam_macos_ver in $gam_macos_vers; do
if version_gt $this_macos_ver $gam_macos_ver; then
use_macos_ver="MacOS$gam_macos_ver"
echo_green "Using GAM compiled on $use_macos_ver"
break
fi
done
if [ "$use_macos_ver" == "" ]; then
echo_red "Sorry, you need to be running at least MacOS $gam_macos_ver to run GAM"
exit
fi
gamfile="macos-x86_64.tar.xz"
;;
MINGW64_NT*)

View File

@@ -1,46 +0,0 @@
# -*- mode: python -*-
import sys
import importlib
from PyInstaller.utils.hooks import copy_metadata
sys.modules['FixTk'] = None
# dynamically determine where httplib2/cacerts.txt lives
proot = os.path.dirname(importlib.import_module('httplib2').__file__)
extra_files = [(os.path.join(proot, 'cacerts.txt'), 'httplib2')]
extra_files += copy_metadata('google-api-python-client')
extra_files += [('cbcm-v1.1beta1.json', '.')]
a = Analysis(['gam/__main__.py'],
hiddenimports=[],
hookspath=None,
excludes=['FixTk', 'tcl', 'tk', '_tkinter', 'tkinter', 'Tkinter'],
datas=extra_files,
runtime_hooks=None)
for d in a.datas:
if 'pyconfig' in d[0]:
a.datas.remove(d)
break
pyz = PYZ(a.pure)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
name='gam',
debug=False,
strip=None,
upx=False,
console=True )
app = BUNDLE(exe,
name='gam.app',
icon=None,
bundle_identifier=None,
info_plist={'LSArchitecturePriority': 'arm64,x86_64'})

View File

@@ -5,8 +5,6 @@ import sys
import importlib
from PyInstaller.utils.hooks import copy_metadata
sys.modules['FixTk'] = None
# dynamically determine where httplib2/cacerts.txt lives
proot = os.path.dirname(importlib.import_module('httplib2').__file__)
extra_files = [(os.path.join(proot, 'cacerts.txt'), 'httplib2')]
@@ -34,6 +32,7 @@ for d in a.datas:
pyz = PYZ(a.pure)
exe = EXE(pyz,
a.scripts,
a.binaries,
@@ -43,4 +42,4 @@ exe = EXE(pyz,
debug=False,
strip=None,
upx=False,
console=True )
console=True)

View File

@@ -6,10 +6,14 @@ import configparser
import csv
import datetime
from email import message_from_string
try:
from importlib.metadata import version as lib_version
except ImportError:
from importlib_metadata import version as lib_version
import io
import json
import mimetypes
import os
import pkg_resources
import platform
from pathlib import Path
import random
@@ -37,6 +41,7 @@ import googleapiclient.errors
import googleapiclient.http
import google.oauth2.service_account
import httplib2
from google.auth.jwt import Credentials as JWTCredentials
from cryptography import x509
from cryptography.hazmat.backends import default_backend
@@ -52,6 +57,7 @@ from gam import fileutils
from gam.gapi import calendar as gapi_calendar
from gam.gapi import cloudidentity as gapi_cloudidentity
from gam.gapi import cbcm as gapi_cbcm
from gam.gapi import chat as gapi_chat
from gam.gapi import chromehistory as gapi_chromehistory
from gam.gapi import chromemanagement as gapi_chromemanagement
from gam.gapi import chromepolicy as gapi_chromepolicy
@@ -618,7 +624,7 @@ TIME_OFFSET_UNITS = [('day', 86400), ('hour', 3600), ('minute', 60),
('second', 1)]
def getLocalGoogleTimeOffset(testLocation='www.googleapis.com'):
def getLocalGoogleTimeOffset(testLocation='admin.googleapis.com'):
localUTC = datetime.datetime.now(datetime.timezone.utc)
try:
# we disable SSL verify so we can still get time even if clock
@@ -732,7 +738,7 @@ def getOSPlatform():
def doGAMVersion(checkForArgs=True):
force_check = extended = simple = timeOffset = False
testLocation = 'www.googleapis.com'
testLocation = 'admin.googleapis.com'
if checkForArgs:
i = 2
while i < len(sys.argv):
@@ -760,8 +766,7 @@ def doGAMVersion(checkForArgs=True):
return
pyversion = platform.python_version()
cpu_bits = struct.calcsize('P') * 8
api_client_ver = pkg_resources.get_distribution(
'google-api-python-client').version
api_client_ver = lib_version('google-api-python-client')
print(
(f'GAM {GAM_VERSION} - {GAM_URL} - {GM_Globals[GM_GAM_TYPE]}\n'
f'{GAM_AUTHOR}\n'
@@ -783,6 +788,21 @@ def doGAMVersion(checkForArgs=True):
doGAMCheckForUpdates(forceCheck=True)
if extended:
print(ssl.OPENSSL_VERSION)
libs = ['cryptography',
'filelock',
'google-auth-httplib2',
'google-auth-oauthlib',
'google-auth',
'httplib2',
'passlib',
'python-dateutil',
'yubikey-manager',
]
for lib in libs:
try:
print(f'{lib} {lib_version(lib)}')
except:
pass
tls_ver, cipher_name, used_ip = _getServerTLSUsed(testLocation)
print(
f'{testLocation} ({used_ip}) connects using {tls_ver} {cipher_name}'
@@ -821,27 +841,39 @@ def _getSvcAcctData():
controlflow.system_error_exit(6, None)
GM_Globals[GM_OAUTH2SERVICE_JSON_DATA] = json.loads(json_string)
def getSvcAcctCredentials(scopes, act_as):
jwt_apis = ['chat'] # APIs which can handle OAuthless JWT tokens
def getSvcAcctCredentials(scopes, act_as, api=None):
try:
_getSvcAcctData()
sign_method = GM_Globals[GM_OAUTH2SERVICE_JSON_DATA].get('key_type', 'default')
if sign_method == 'default':
credentials = google.oauth2.service_account.Credentials.from_service_account_info(
GM_Globals[GM_OAUTH2SERVICE_JSON_DATA])
elif sign_method == 'yubikey':
yksigner = yubikey.YubiKey(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA])
credentials = google.oauth2.service_account.Credentials._from_signer_and_info(yksigner,
GM_Globals[GM_OAUTH2SERVICE_JSON_DATA])
credentials = credentials.with_scopes(scopes)
if act_as:
credentials = credentials.with_subject(act_as)
if act_as or api not in jwt_apis:
if sign_method == 'default':
credentials = google.oauth2.service_account.Credentials.from_service_account_info(
GM_Globals[GM_OAUTH2SERVICE_JSON_DATA])
elif sign_method == 'yubikey':
yksigner = yubikey.YubiKey(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA])
credentials = google.oauth2.service_account.Credentials._from_signer_and_info(yksigner,
GM_Globals[GM_OAUTH2SERVICE_JSON_DATA])
credentials = credentials.with_scopes(scopes)
if act_as:
credentials = credentials.with_subject(act_as)
else:
audience = f'https://{api}.googleapis.com/'
if sign_method == 'default':
credentials = JWTCredentials.from_service_account_info(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA],
audience=audience)
elif sign_method == 'yubikey':
yksigner = yubikey.YubiKey(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA])
credentials = JWTCredentials._from_signer_and_info(yksigner,
GM_Globals[GM_OAUTH2SERVICE_JSON_DATA],
audience=audience)
credentials.project_id = GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]['project_id']
GM_Globals[GM_OAUTH2SERVICE_ACCOUNT_CLIENT_ID] = GM_Globals[
GM_OAUTH2SERVICE_JSON_DATA]['client_id']
return credentials
except (ValueError, KeyError):
except (ValueError, KeyError) as err:
printLine(MESSAGE_INSTRUCTIONS_OAUTH2SERVICE_JSON)
controlflow.invalid_json_exit(GC_Values[GC_OAUTH2SERVICE_JSON])
controlflow.invalid_json_exit(GC_Values[GC_OAUTH2SERVICE_JSON], err)
def getAPIVersion(api):
@@ -867,8 +899,8 @@ def readDiscoveryFile(api_version):
try:
discovery = json.loads(json_string)
return (disc_file, discovery)
except ValueError:
controlflow.invalid_json_exit(disc_file)
except ValueError as err:
controlflow.invalid_json_exit(disc_file, err)
def getOauth2TxtStorageCredentials():
@@ -1086,14 +1118,17 @@ def convertEmailAddressToUID(emailAddressOrUID, cd=None, email_type='user'):
return normalizedEmailAddressOrUID
def buildGAPIServiceObject(api, act_as, showAuthError=True):
def buildGAPIServiceObject(api, act_as, showAuthError=True, scopes=None):
httpObj = transport.create_http(cache=GM_Globals[GM_CACHE_DIR])
service = getService(api, httpObj)
GM_Globals[GM_CURRENT_API_USER] = act_as
GM_Globals[GM_CURRENT_API_SCOPES] = API_SCOPE_MAPPING.get(
api, service._rootDesc['auth']['oauth2']['scopes'])
if scopes:
GM_Globals[GM_CURRENT_API_SCOPES] = scopes
else:
GM_Globals[GM_CURRENT_API_SCOPES] = API_SCOPE_MAPPING.get(
api, service._rootDesc['auth']['oauth2']['scopes'])
credentials = getSvcAcctCredentials(GM_Globals[GM_CURRENT_API_SCOPES],
act_as)
act_as, api)
request = transport.create_request(httpObj)
retries = 3
for n in range(1, retries + 1):
@@ -1168,7 +1203,7 @@ def doCheckServiceAccount(users):
time_status = test_fail
printPassFail(
MESSAGE_YOUR_SYSTEM_TIME_DIFFERS_FROM_GOOGLE_BY %
('www.googleapis.com', nicetime), time_status)
('admin.googleapis.com', nicetime), time_status)
oa2 = getService('oauth2', transport.create_http())
print('Service Account Private Key Authentication:')
# We are explicitly not doing DwD here, just confirming service account can auth
@@ -1414,7 +1449,13 @@ def addDelegates(users, i):
if sys.argv[i].lower() != 'to':
controlflow.missing_argument_exit('to', 'gam <users> delegate')
i += 1
convertAlias = False
if sys.argv[i].lower().replace('_', '') == 'convertalias':
convertAlias = True
i += 1
delegate = normalizeEmailAddressOrUID(sys.argv[i], noUid=True)
if convertAlias:
delegate = gapi_directory_users.get_primary(delegate)
i = 0
count = len(users)
for delegator in users:
@@ -1432,8 +1473,8 @@ def addDelegates(users, i):
body={'delegateEmail': delegate})
def gen_sha512_hash(password):
return sha512_crypt.hash(password, rounds=5000)
def gen_sha512_hash(password, rounds=10000):
return sha512_crypt.hash(password, rounds=rounds)
def printShowDelegates(users, csvFormat):
@@ -1493,7 +1534,14 @@ def printShowDelegates(users, csvFormat):
def deleteDelegate(users):
delegate = normalizeEmailAddressOrUID(sys.argv[5], noUid=True)
convertAlias = False
i = 5
if sys.argv[i].lower().replace('_', '') == 'convertalias':
convertAlias = True
i += 1
delegate = normalizeEmailAddressOrUID(sys.argv[i], noUid=True)
if convertAlias:
delegate = gapi_directory_users.get_primary(delegate)
i = 0
count = len(users)
for user in users:
@@ -3569,14 +3617,19 @@ def getDriveFileAttribute(i, body, parameters, myarg, update=False):
operation = 'update' if update else 'add'
if myarg == 'localfile':
parameters[DFA_LOCALFILEPATH] = sys.argv[i + 1]
parameters[DFA_LOCALFILENAME] = os.path.basename(
parameters[DFA_LOCALFILEPATH])
body.setdefault('title', parameters[DFA_LOCALFILENAME])
body['mimeType'] = mimetypes.guess_type(
parameters[DFA_LOCALFILEPATH])[0]
if body['mimeType'] is None:
body['mimeType'] = 'application/octet-stream'
parameters[DFA_LOCALMIMETYPE] = body['mimeType']
if parameters[DFA_LOCALFILEPATH] != '-':
parameters[DFA_LOCALFILENAME] = os.path.basename(
parameters[DFA_LOCALFILEPATH])
body.setdefault('title', parameters[DFA_LOCALFILENAME])
body['mimeType'] = mimetypes.guess_type(
parameters[DFA_LOCALFILEPATH])[0]
if body['mimeType'] is None:
body['mimeType'] = 'application/octet-stream'
parameters[DFA_LOCALMIMETYPE] = body['mimeType']
else:
parameters[DFA_LOCALFILENAME] = '-'
if body.get('mimeType') is None:
body['mimeType'] = 'application/octet-stream'
i += 2
elif myarg == 'convert':
parameters[DFA_CONVERT] = True
@@ -3666,12 +3719,32 @@ def getDriveFileAttribute(i, body, parameters, myarg, update=False):
body['mimeType'] = MIMETYPE_GA_SHORTCUT
body['shortcutDetails'] = {'targetId': sys.argv[i+1]}
i += 2
elif myarg == 'securityupdate':
body['linkShareMetadata'] = {'securityUpdateEnabled': getBoolean(
sys.argv[i+1], f'gam <users> {operation} drivefile'), 'securityUpdateEligible': True}
i += 2
else:
controlflow.invalid_argument_exit(
myarg, f"gam <users> {operation} drivefile")
return i
def get_media_body(parameters, body):
if parameters[DFA_LOCALFILEPATH] != '-':
media_body = googleapiclient.http.MediaFileUpload(parameters[DFA_LOCALFILEPATH], mimetype=parameters[DFA_LOCALMIMETYPE], resumable=True)
else:
if body['mimeType'] == MIMETYPE_GA_SPREADSHEET:
mimetype = 'text/csv'
elif body['mimeType'] == MIMETYPE_GA_DOCUMENT:
mimetype = 'text/plain'
else:
mimetype = 'application/octet-stream'
media_body = googleapiclient.http.MediaIoBaseUpload(io.BytesIO(sys.stdin.buffer.read()), mimetype, resumable=True)
if media_body.size() == 0:
media_body = None
return media_body
def has_multiple_parents(body):
return len(body.get('parents', [])) > 1
@@ -3714,6 +3787,8 @@ def doUpdateDriveFile(users):
2,
'you cannot specify multiple file identifiers. Choose one of id, drivefilename, query.'
)
if operation == 'update' and parameters[DFA_LOCALFILEPATH]:
media_body = get_media_body(parameters, body)
for user in users:
user, drive = buildDriveGAPIObject(user)
if not drive:
@@ -3734,11 +3809,6 @@ def doUpdateDriveFile(users):
print(f'No files to {operation} for {user}')
continue
if operation == 'update':
if parameters[DFA_LOCALFILEPATH]:
media_body = googleapiclient.http.MediaFileUpload(
parameters[DFA_LOCALFILEPATH],
mimetype=parameters[DFA_LOCALMIMETYPE],
resumable=True)
for fileId in fileIdSelection['fileIds']:
if media_body:
result = gapi.call(drive.files(),
@@ -3804,6 +3874,8 @@ def createDriveFile(users):
i += 1
else:
i = getDriveFileAttribute(i, body, parameters, myarg, False)
if parameters[DFA_LOCALFILEPATH]:
media_body = get_media_body(parameters, body)
for user in users:
user, drive = buildDriveGAPIObject(user)
if not drive:
@@ -3817,11 +3889,6 @@ def createDriveFile(users):
if has_multiple_parents(body):
sys.stderr.write(f"Multiple parents ({len(body['parents'])}) specified for {user}, only one is allowed.\n")
continue
if parameters[DFA_LOCALFILEPATH]:
media_body = googleapiclient.http.MediaFileUpload(
parameters[DFA_LOCALFILEPATH],
mimetype=parameters[DFA_LOCALMIMETYPE],
resumable=True)
result = gapi.call(drive.files(),
'insert',
convert=parameters[DFA_CONVERT],
@@ -5324,20 +5391,27 @@ def gmail_del_result(request_id, response, exception):
print(exception)
def showLabels(users):
def printShowLabels(users, show=True):
i = 5
onlyUser = showCounts = False
onlyUser = False
showCounts = False
todrive = False
while i < len(sys.argv):
myarg = sys.argv[i].lower().replace('_', '')
if myarg == 'onlyuser':
onlyUser = True
i += 1
elif myarg == 'todrive':
todrive = True
i += 1
elif myarg == 'showcounts':
showCounts = True
i += 1
else:
controlflow.invalid_argument_exit(sys.argv[i],
'gam <users> show labels')
'gam <users> show labels')
if not show:
titles = ['email']
for user in users:
user, gmail = buildGmailGAPIObject(user)
if not gmail:
@@ -5345,28 +5419,45 @@ def showLabels(users):
labels = gapi.call(gmail.users().labels(),
'list',
userId=user,
soft_errors=True)
if labels:
for label in labels['labels']:
if onlyUser and (label['type'] == 'system'):
continue
soft_errors=True).get('labels', [])
i = 0
for label in labels:
i += 1
if onlyUser and (label['type'] == 'system'):
continue
if showCounts:
if i >= 50 and not i % 50:
# show label get count for greater than 100 labels
# every 100 labels
sys.stderr.write('\r')
sys.stderr.flush()
sys.stderr.write(f'Getting counts for label {i} of {len(labels)}')
counts = gapi.call(
gmail.users().labels(),
'get',
userId=user,
id=label['id'],
fields=
'messagesTotal,messagesUnread,threadsTotal,threadsUnread'
)
label.update(counts)
if show:
print(label['name'])
for a_key in label:
if a_key == 'name':
continue
print(f' {a_key}: {label[a_key]}')
if showCounts:
counts = gapi.call(
gmail.users().labels(),
'get',
userId=user,
id=label['id'],
fields=
'messagesTotal,messagesUnread,threadsTotal,threadsUnread'
)
for a_key in counts:
print(f' {a_key}: {counts[a_key]}')
print('')
else:
for key in label:
if key not in titles:
titles.append(key)
label['email'] = user
if not show:
display.write_csv_file(labels,
titles,
'Gmail Labels',
todrive)
def showGmailProfile(users):
@@ -7037,9 +7128,14 @@ def getUserAttributes(i, cd, updateCmd):
controlflow.invalid_argument_exit(
sys.argv[i], f"gam {['create', 'update'][updateCmd]} user")
if need_password:
# generate a password with unicode chars that are not allowed in
# passwords. We expect "password random nohash" to fail but no one
# should be using that. Our goal here is to purposefully block login
# with this password.
pass_chars = [chr(i) for i in range(1, 55296)]
rnd = SystemRandom()
body['password'] = ''.join(
rnd.choice(PASSWORD_SAFE_CHARS) for _ in range(100))
rnd.choice(pass_chars) for _ in range(4096))
if 'password' in body and need_to_hash_password:
body['password'] = gen_sha512_hash(body['password'])
body['hashFunction'] = 'crypt'
@@ -7062,12 +7158,7 @@ def getCRMService(login_hint):
login_hint=login_hint,
use_console_flow=not GC_Values[GC_OAUTH_BROWSER])
httpc = transport.AuthorizedHttp(creds, transport.create_http())
return getService('cloudresourcemanagerv1', httpc), httpc
# Ugh, v2 doesn't contain all the operations of v1 so we need to use both here.
def getCRM2Service(httpc):
return getService('cloudresourcemanager', httpc)
return getService('cloudresourcemanager', httpc), httpc
def getGAMProjectFile(filepath):
@@ -7167,19 +7258,19 @@ def enableGAMProjectAPIs(GAMProjectAPIs,
return status
def _grantSARotateRights(iam, sa_email):
print(f'Giving service account {sa_email} rights to rotate own private key')
def _grantRotateRights(iam, service_account, email, account_type='serviceAccount'):
print(f'Giving account {email} rights to rotate {service_account} private key')
body = {
'policy': {
'bindings': [{
'role': 'roles/iam.serviceAccountKeyAdmin',
'members': [f'serviceAccount:{sa_email}']
'members': [f'{account_type}:{email}']
}]
}
}
gapi.call(iam.projects().serviceAccounts(),
'setIamPolicy',
resource=f'projects/-/serviceAccounts/{sa_email}',
resource=f'projects/-/serviceAccounts/{service_account}',
body=body)
@@ -7271,11 +7362,12 @@ def _createClientSecretsOauth2service(httpObj, projectId, login_hint):
})
GM_Globals[GM_OAUTH2SERVICE_ACCOUNT_CLIENT_ID] = service_account[
'uniqueId']
sa_email = service_account['name'].rsplit('/', 1)[-1]
doCreateOrRotateServiceAccountKeys(iam,
project_id=service_account['projectId'],
client_email=service_account['email'],
client_id=service_account['uniqueId'])
_grantSARotateRights(iam, service_account['name'].rsplit('/', 1)[-1])
_grantRotateRights(iam, sa_email, sa_email)
console_url = f'https://console.cloud.google.com/apis/credentials/oauthclient?project={projectId}'
while True:
print(f'''Please go to:
@@ -7360,10 +7452,10 @@ def _getProjects(crm, pfilter):
try:
return gapi.get_all_pages(
crm.projects(),
'list',
'search',
'projects',
throw_reasons=[gapi_errors.ErrorReason.BAD_REQUEST],
filter=pfilter)
query=pfilter)
except gapi_errors.GapiBadRequestError as e:
controlflow.system_error_exit(2, f'Project: {pfilter}, {str(e)}')
@@ -7425,23 +7517,15 @@ def _getLoginHintProjectId(createCmd):
f'Invalid Project ID: {projectId}, expected <{PROJECTID_FORMAT_REQUIRED}>'
)
crm, httpObj = getCRMService(login_hint)
if parent and not parent.startswith(
'organizations/') and not parent.startswith('folders/'):
crm2 = getCRM2Service(httpObj)
parent = convertGCPFolderNameToID(parent, crm2)
if parent:
parent_type, parent_id = parent.split('/')
if parent_type[-1] == 's':
parent_type = parent_type[:
-1] # folders > folder, organizations > organization
parent = {'type': parent_type, 'id': parent_id}
if parent and not parent.startswith('organizations/') and not parent.startswith('folders/'):
parent = convertGCPFolderNameToID(parent, crm)
projects = _getProjects(crm, f'id:{projectId}')
if not createCmd:
if not projects:
controlflow.system_error_exit(
2,
f'User: {login_hint}, Project ID: {projectId}, Does not exist')
if projects[0]['lifecycleState'] != 'ACTIVE':
if projects[0]['state'] != 'ACTIVE':
controlflow.system_error_exit(
2, f'User: {login_hint}, Project ID: {projectId}, Not active')
else:
@@ -7454,17 +7538,11 @@ def _getLoginHintProjectId(createCmd):
PROJECTID_FILTER_REQUIRED = 'gam|<ProjectID>|(filter <String>)'
def convertGCPFolderNameToID(parent, crm2):
# crm2.folders() is broken requiring pageToken, etc in body, not URL.
# for now just use gapi.get_items and if user has that many folders they'll
# just need to be specific.
folders = gapi.get_items(crm2.folders(),
'search',
items='folders',
body={
'pageSize': 1000,
'query': f'displayName="{parent}"'
})
def convertGCPFolderNameToID(parent, crm):
folders = gapi.get_all_pages(crm.folders(),
'search',
'folders',
query=f'displayName="{parent}"')
if not folders:
controlflow.system_error_exit(
1, f'ERROR: No folder found matching displayName={parent}')
@@ -7478,15 +7556,14 @@ def convertGCPFolderNameToID(parent, crm2):
def createGCPFolder():
displayName = sys.argv[3]
login_hint = _getValidateLoginHint()
_, httpObj = getCRMService(login_hint)
crm2 = getCRM2Service(httpObj)
gapi.call(crm2.folders(),
'create',
body={
'name': sys.argv[3],
'displayName': sys.argv[3]
})
login_domain = login_hint.split('@')[-1]
crm, _ = getCRMService(login_hint)
organization = getGCPOrg(crm, login_domain)
result = gapi.call(crm.folders(), 'create',
body={'parent': organization, 'displayName': displayName})
print(f'User: {login_hint}, Folder: {displayName}, GCP Folder Name: {result["name"]}, Created')
def _getLoginHintProjects(printShowCmd):
@@ -7540,16 +7617,31 @@ def _checkForExistingProjectFiles():
)
def getGCPOrg(crm, domain):
resp = gapi.call(crm.organizations(),
'search',
query=f'domain:{domain}')
try:
organization = resp['organizations'][0]['name']
print(f'Your organization name is {organization}')
return organization
except (KeyError, IndexError):
controlflow.system_error_exit(
3,
'you have no rights to create projects for your organization and you don\'t seem to be a super admin! Sorry, there\'s nothing more I can do.'
)
def doCreateProject():
_checkForExistingProjectFiles()
crm, httpObj, login_hint, projectId, parent = _getLoginHintProjectId(True)
login_domain = login_hint[login_hint.find('@') + 1:]
body = {'projectId': projectId, 'name': 'GAM Project'}
body = {'projectId': projectId, 'displayName': 'GAM Project'}
if parent:
body['parent'] = parent
while True:
create_again = False
print(f'Creating project "{body["name"]}"...')
print(f'Creating project "{body["displayName"]}"...')
create_operation = gapi.call(crm.projects(), 'create', body=body)
operation_name = create_operation['name']
time.sleep(8) # Google recommends always waiting at least 5 seconds
@@ -7564,18 +7656,7 @@ def doCreateProject():
'Hmm... Looks like you have no rights to your Google Cloud Organization.'
)
print('Attempting to fix that...')
getorg = gapi.call(
crm.organizations(),
'search',
body={'filter': f'domain:{login_domain}'})
try:
organization = getorg['organizations'][0]['name']
print(f'Your organization name is {organization}')
except (KeyError, IndexError):
controlflow.system_error_exit(
3,
'you have no rights to create projects for your organization and you don\'t seem to be a super admin! Sorry, there\'s nothing more I can do.'
)
organization = getGCPOrg(crm, login_domain)
org_policy = gapi.call(crm.organizations(),
'getIamPolicy',
resource=organization)
@@ -7656,7 +7737,7 @@ def doUpdateProjects():
iam = getService('iam', httpObj)
_getSvcAcctData() # needed to read in GM_OAUTH2SERVICE_JSON_DATA
sa_email = GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]['client_email']
_grantSARotateRights(iam, sa_email)
_grantRotateRights(iam, sa_email, sa_email)
def _generatePrivateKeyAndPublicCert(client_id, key_size):
@@ -7808,7 +7889,7 @@ def doCreateOrRotateServiceAccountKeys(iam=None,
i += 1
elif myarg == 'yubikeyslot':
new_data['yubikey_slot'] = sys.argv[i+1].upper()
i =+ 2
i += 2
elif myarg == 'yubikeypin':
new_data['yubikey_pin'] = input('Enter your YubiKey PIN: ')
i += 1
@@ -7831,6 +7912,10 @@ def doCreateOrRotateServiceAccountKeys(iam=None,
new_data['yubikey_key_type'] = f'RSA{local_key_size}'
new_data.pop('private_key', None)
yk = yubikey.YubiKey(new_data)
if 'yubikey_serial_number' not in new_data:
new_data['yubikey_serial_number'] = yk.get_serial_number()
if 'yubikey_slot' not in new_data:
new_data['yubikey_slot'] = 'AUTHENTICATION'
publicKeyData = yk.get_certificate()
elif local_key_size:
# Generate private key locally, store in file
@@ -7854,6 +7939,7 @@ def doCreateOrRotateServiceAccountKeys(iam=None,
iam.projects().serviceAccounts().keys(),
'upload',
throw_reasons=throw_reasons,
retry_reasons=[gapi_errors.ErrorReason.FOUR_O_THREE],
name=sa_name,
body={'publicKeyData': publicKeyData})
break
@@ -7878,6 +7964,7 @@ def doCreateOrRotateServiceAccountKeys(iam=None,
result = gapi.call(iam.projects().serviceAccounts().keys(),
'create',
name=sa_name,
retry_reasons=[gapi_errors.ErrorReason.FOUR_O_THREE],
body=body)
new_data_str = base64.b64decode(
result['privateKeyData']).decode(UTF8)
@@ -7903,6 +7990,7 @@ def doCreateOrRotateServiceAccountKeys(iam=None,
print(f' Revoking existing key {keyName} for service account')
gapi.call(iam.projects().serviceAccounts().keys(),
'delete',
retry_reasons=[gapi_errors.ErrorReason.FOUR_O_THREE],
name=key['name'])
if mode != 'retainnone':
break
@@ -7961,7 +8049,7 @@ def doDelProjects():
gapi.call(crm.projects(),
'delete',
throw_reasons=[gapi_errors.ErrorReason.FORBIDDEN],
projectId=projectId)
name=project['name'])
print(f' Project: {projectId} Deleted{currentCount(i, count)}')
except gapi_errors.GapiForbiddenError as e:
print(
@@ -7975,8 +8063,9 @@ def doPrintShowProjects(csvFormat):
csvRows = []
todrive = False
titles = [
'User', 'projectId', 'projectNumber', 'name', 'createTime',
'lifecycleState'
'User', 'projectId', 'name', 'displayName',
'createTime', 'updateTime', 'deleteTime',
'state'
]
while i < len(sys.argv):
myarg = sys.argv[i].lower()
@@ -7993,19 +8082,19 @@ def doPrintShowProjects(csvFormat):
for project in projects:
i += 1
print(f' Project: {project["projectId"]}{currentCount(i, count)}')
print(f' projectNumber: {project["projectNumber"]}')
print(f' name: {project["name"]}')
print(f' createTime: {project["createTime"]}')
print(f' lifecycleState: {project["lifecycleState"]}')
print(f' displayName: {project["displayName"]}')
for field in ['createTime', 'updateTime', 'deleteTime']:
if field in project:
print(f' {field}: {project[field]}')
print(f' state: {project["state"]}')
jcount = len(project.get('labels', []))
if jcount > 0:
print(' labels:')
for k, v in list(project['labels'].items()):
print(f' {k}: {v}')
if 'parent' in project:
print(' parent:')
print(f' type: {project["parent"]["type"]}')
print(f' id: {project["parent"]["id"]}')
print(f' parent: {project["parent"]}')
else:
for project in projects:
display.add_row_titles_to_csv_file(
@@ -8147,6 +8236,7 @@ def printShowTeamDrives(users, csvFormat):
controlflow.invalid_argument_exit(
myarg, f"gam {['show', 'print'][csvFormat]} teamdrives")
tds = []
titles = []
for user in users:
sys.stderr.write(f'Getting Team Drives for {user}\n')
user, drive = buildDrive3GAPIObject(user)
@@ -8162,20 +8252,21 @@ def printShowTeamDrives(users, csvFormat):
if not results:
continue
for td in results:
if 'id' not in td:
continue
if 'name' not in td:
td['name'] = '<Unknown Team Drive>'
this_td = {'id': td['id'], 'name': td['name']}
if this_td in tds:
continue
tds.append({'id': td['id'], 'name': td['name']})
td = utils.flatten_json(td)
for key in td:
if key not in titles:
titles.append(key)
tds.append(td)
if csvFormat:
titles = ['name', 'id']
display.write_csv_file(tds, titles, 'Team Drives', todrive)
else:
for td in tds:
print(f'Name: {td["name"]} ID: {td["id"]}')
name = td.pop('name')
my_id = td.pop('id')
print(f'Name: {name} ID: {my_id}')
display.print_json(td)
print()
def doDeleteTeamDrive(users):
@@ -8413,7 +8504,7 @@ def doWhatIs():
except (gapi_errors.GapiGroupNotFoundError, gapi_errors.GapiNotFoundError,
gapi_errors.GapiBadRequestError, gapi_errors.GapiForbiddenError):
sys.stderr.write(f'{email} is not a group...\n')
sys.stderr.write(f'{email} is not a proup alias...\n')
sys.stderr.write(f'{email} is not a group alias...\n')
if gapi_cloudidentity_userinvitations.is_invitable_user(email):
sys.stderr.write(f'{email} is an unmanaged account\n\n')
else:
@@ -8676,7 +8767,11 @@ def doGetUserInfo(user_email=None):
i = 4
else:
user_email = _get_admin_email()
getSchemas = getAliases = getGroups = getLicenses = True
getSchemas = True
getAliases = True
getGroups = True
getCIGroups = False
getLicenses = True
projection = 'full'
customFieldMask = viewType = None
skus = sorted(SKUS)
@@ -8688,6 +8783,10 @@ def doGetUserInfo(user_email=None):
elif myarg == 'nogroups':
getGroups = False
i += 1
elif myarg == 'grouptree':
getCIGroups = True
getGroups = False
i += 1
elif myarg in ['nolicenses', 'nolicences']:
getLicenses = False
i += 1
@@ -8953,6 +9052,34 @@ def doGetUserInfo(user_email=None):
print(f' {group["name"]} <{group["email"]}>')
except gapi.errors.GapiForbiddenError:
print('No access to show user groups.')
elif getCIGroups:
memberships = gapi_cloudidentity_groups.get_membership_graph(user_email)
print('Group Membership Tree:')
if memberships:
group_name_mapping = {}
group_displayname_mapping = {}
groups = memberships.get('groups', [])
for group in groups:
group_name = group.get('name')
group_key = group.get('groupKey', {})
group_email = group_key.get('id', '')
group_display_name = group.get('displayName', '')
group_name_mapping[group_name] = group_email
group_displayname_mapping[group_email] = group_display_name
edges = []
seen_group_count = {}
for adj in memberships.get('adjacencyList', []):
group_name = adj.get('group', '')
group_email = group_name_mapping[group_name]
for edge in adj.get('edges', []):
seen_group_count[group_email] = seen_group_count.get(group_email, 0) + 1
member_email = edge.get('memberKey', {}).get('id')
edges.append((member_email, group_email))
print_group_map(user_email, group_displayname_mapping, seen_group_count, edges, 3, 'direct')
if seen_group_count and max(seen_group_count.values()) > 1:
print()
print(' * user has multiple direct or inherited memberships in group')
print()
if getLicenses:
print('Licenses:')
lic = buildGAPIObject('licensing')
@@ -8968,6 +9095,15 @@ def doGetUserInfo(user_email=None):
for user_license in user_licenses:
print(f' {gapi_licensing._formatSKUIdDisplayName(user_license)}')
def print_group_map(parent, group_name_mappings, seen_group_count, edges, spaces, direction):
for a_parent, a_child in edges:
if a_parent == parent:
group_display_name = group_name_mappings[a_child]
output = f'{" " * spaces}{group_display_name} <{a_child}> ({direction})'
if seen_group_count[a_child] > 1:
output += ' *'
print(output)
print_group_map(a_child, group_name_mappings, seen_group_count, edges, spaces+2, 'inherited')
def doGetAliasInfo(alias_email=None):
cd = buildGAPIObject('directory')
@@ -11242,6 +11378,8 @@ def ProcessGAMCommand(args):
gapi_cbcm.createtoken()
elif argument in ['printer']:
gapi_directory_printers.create()
elif argument in ['chatmessage']:
gapi_chat.create_message()
else:
controlflow.invalid_argument_exit(argument, 'gam create')
sys.exit(0)
@@ -11304,6 +11442,8 @@ def ProcessGAMCommand(args):
gapi_chromepolicy.update_policy()
elif argument in ['printer']:
gapi_directory_printers.update()
elif argument in ['chatmessage']:
gapi_chat.update_message()
else:
controlflow.invalid_argument_exit(argument, 'gam update')
sys.exit(0)
@@ -11440,6 +11580,8 @@ def ProcessGAMCommand(args):
gapi_directory_printers.delete()
elif argument == 'chromepolicy':
gapi_chromepolicy.delete_policy()
elif argument == 'chatmessage':
gapi_chat.delete_message()
else:
controlflow.invalid_argument_exit(argument, 'gam delete')
sys.exit(0)
@@ -11555,6 +11697,10 @@ def ProcessGAMCommand(args):
gapi_chromemanagement.printVersions()
elif argument in ['chromehistory']:
gapi_chromehistory.printHistory()
elif argument in ['chatspaces']:
gapi_chat.print_spaces()
elif argument in ['chatmembers']:
gapi_chat.print_members()
else:
controlflow.invalid_argument_exit(argument, 'gam print')
sys.exit(0)
@@ -11705,6 +11851,12 @@ def ProcessGAMCommand(args):
elif command == 'getcommand':
gapi_directory_cros.get_command()
sys.exit(0)
elif command in ['yubikey']:
action = sys.argv[2].lower().replace('_', '')
if action == 'resetpiv':
yk = yubikey.YubiKey()
yk.reset_piv()
sys.exit(0)
users = getUsersToModify()
command = sys.argv[3].lower()
if command == 'print' and len(sys.argv) == 4:
@@ -11726,7 +11878,7 @@ def ProcessGAMCommand(args):
elif command == 'show':
showWhat = sys.argv[4].lower()
if showWhat in ['labels', 'label']:
showLabels(users)
printShowLabels(users)
elif showWhat == 'profile':
showProfile(users)
elif showWhat == 'calendars':
@@ -11815,6 +11967,8 @@ def ProcessGAMCommand(args):
printShowTeamDrives(users, True)
elif printWhat in ['contactdelegate', 'contactdelegates']:
gapi_contactdelegation.print_(users, True)
elif printWhat in ['labels']:
printShowLabels(users, show=False)
else:
controlflow.invalid_argument_exit(printWhat,
'gam <users> print')

View File

@@ -1,72 +1,151 @@
from base64 import b64encode
import datetime
from secrets import SystemRandom
import string
import sys
from threading import Timer
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from ykman.device import connect_to_device
from yubikit.piv import KEY_TYPE, SLOT, InvalidPinError, PivSession
from ykman.piv import generate_self_signed_certificate, \
generate_chuid
from yubikit.piv import DEFAULT_MANAGEMENT_KEY, \
InvalidPinError, \
KEY_TYPE, \
MANAGEMENT_KEY_TYPE, \
PIN_POLICY, \
PivSession, \
OBJECT_ID, \
SLOT, \
TOUCH_POLICY
from yubikit.core.smartcard import ApduError
from gam import controlflow
class YubiKey():
def __init__(self, service_account_info):
key_type = service_account_info.get('yubikey_key_type', 'RSA2048')
try:
self.key_type = getattr(KEY_TYPE, key_type.upper())
except AttributeError:
controlflow.system_error_exit(6, f'{key_type} is not a valid value for yubikey_key_type')
slot = service_account_info.get('yubikey_slot', 'AUTHENTICATION')
try:
self.slot = getattr(SLOT, slot.upper())
except AttributeError:
controlflow.system_error_exit(6, f'{slot} is not a valid value for yubikey_slot')
self.serial_number = service_account_info.get('yubikey_serial_number')
self.pin = service_account_info.get('yubikey_pin')
self.key_id = service_account_info.get('private_key_id')
def __init__(self, service_account_info=None):
self.key_type = None
self.slot = None
self.serial_number = None
self.pin = None
self.key_id = None
if service_account_info:
key_type = service_account_info.get('yubikey_key_type', 'RSA2048')
try:
self.key_type = getattr(KEY_TYPE, key_type.upper())
except AttributeError:
controlflow.system_error_exit(6, f'{key_type} is not a valid value for yubikey_key_type')
slot = service_account_info.get('yubikey_slot', 'AUTHENTICATION')
try:
self.slot = getattr(SLOT, slot.upper())
except AttributeError:
controlflow.system_error_exit(6, f'{slot} is not a valid value for yubikey_slot')
self.serial_number = service_account_info.get('yubikey_serial_number')
self.pin = service_account_info.get('yubikey_pin')
self.key_id = service_account_info.get('private_key_id')
def _connect(self):
conn, _, _ = connect_to_device(self.serial_number)
return conn
def get_certificate(self):
try:
conn, _, _ = connect_to_device(self.serial_number)
session = PivSession(conn)
if self.pin:
conn = self._connect()
with conn:
session = PivSession(conn)
if self.pin:
try:
session.verify_pin(self.pin)
except InvalidPinError as err:
controlflow.system_error_exit(7, f'YubiKey - {err}')
try:
session.verify_pin(self.pin)
except InvalidPinError as err:
controlflow.system_error_exit(7, f'YubiKey - {err}')
try:
cert = session.get_certificate(self.slot)
cert_pem = cert.public_bytes(
serialization.Encoding.PEM).decode()
publicKeyData = b64encode(cert_pem.encode())
if isinstance(publicKeyData, bytes):
publicKeyData = publicKeyData.decode()
return publicKeyData
except ApduError as err:
controlflow.system_error_exit(8, f'YubiKey - {err}')
cert = session.get_certificate(self.slot)
except ApduError as err:
controlflow.system_error_exit(9, f'Yubikey = {err}')
cert_pem = cert.public_bytes(
serialization.Encoding.PEM).decode()
publicKeyData = b64encode(cert_pem.encode())
if isinstance(publicKeyData, bytes):
publicKeyData = publicKeyData.decode()
return publicKeyData
except ValueError as err:
controlflow.system_error_exit(9, f'YubiKey - {err}')
def get_serial_number(self):
try:
_, _, info = connect_to_device(self.serial_number)
return info.serial
except ValueError as err:
controlflow.system_error_exit(9, f'YubikKey = {err}')
def reset_piv(self):
'''Resets YubiKey PIV app and generates new key for GAM to use.'''
reply = str(input('This will wipe all PIV keys and configuration from your YubiKey. Are you sure? (y/N) ').lower().strip())
if reply != 'y':
sys.exit(1)
try:
conn = self._connect()
with conn:
piv = PivSession(conn)
piv.reset()
rnd = SystemRandom()
pin_puk_chars = string.ascii_letters + string.digits + string.punctuation
new_puk = ''.join(rnd.choice(pin_puk_chars) for _ in range(8))
new_pin = ''.join(rnd.choice(pin_puk_chars) for _ in range(8))
piv.change_puk('12345678', new_puk)
piv.change_pin('123456', new_pin)
print(f'PIN set to: {new_pin}')
piv.authenticate(MANAGEMENT_KEY_TYPE.TDES,
DEFAULT_MANAGEMENT_KEY)
piv.verify_pin(new_pin)
print('Yubikey is generating a non-exportable private key...')
pubkey = piv.generate_key(SLOT.AUTHENTICATION,
KEY_TYPE.RSA2048,
PIN_POLICY.ALWAYS,
TOUCH_POLICY.NEVER)
now = datetime.datetime.utcnow()
valid_to = now + datetime.timedelta(days=36500)
subject = 'CN=GAM Created Key'
piv.authenticate(MANAGEMENT_KEY_TYPE.TDES,
DEFAULT_MANAGEMENT_KEY)
piv.verify_pin(new_pin)
cert = generate_self_signed_certificate(piv,
SLOT.AUTHENTICATION,
pubkey,
subject,
now,
valid_to)
piv.put_certificate(SLOT.AUTHENTICATION,
cert)
piv.put_object(OBJECT_ID.CHUID,
generate_chuid())
except ValueError as err:
controlflow.system_error_exit(8, f'Yubikey - {err}')
def sign(self, message):
if 'mplock' in globals():
mplock.acquire()
try:
conn, _, _ = connect_to_device(self.serial_number)
session = PivSession(conn)
if self.pin:
conn = self._connect()
with conn:
session = PivSession(conn)
if self.pin:
try:
session.verify_pin(self.pin)
except InvalidPinError as err:
controlflow.system_error_exit(7, f'YubiKey - {err}')
try:
session.verify_pin(self.pin)
except InvalidPinError as err:
controlflow.system_error_exit(7, f'YubiKey - {err}')
try:
signed = session.sign(slot=self.slot,
signed = session.sign(slot=self.slot,
key_type=self.key_type,
message=message,
hash_algorithm=hashes.SHA256(),
padding=padding.PKCS1v15())
except ApduError as err:
controlflow.system_error_exit(8, f'YubiKey = {err}')
except ApduError as err:
controlflow.system_error_exit(8, f'YubiKey = {err}')
except ValueError as err:
controlflow.system_error_exit(9, f'YubiKey - {err}')
if 'mplock' in globals():

View File

@@ -65,9 +65,12 @@ def csv_field_error_exit(field_name, field_names):
','.join(field_names)))
def invalid_json_exit(file_name):
def invalid_json_exit(file_name, err=None):
"""Raises a system exit when invalid JSON content is encountered."""
system_error_exit(17, MESSAGE_INVALID_JSON.format(file_name))
err_msg = MESSAGE_INVALID_JSON.format(file_name)
if err:
err_msg += f'\n\n{err}'
system_error_exit(17, err_msg)
def wait_on_failure(current_attempt_num,

View File

@@ -281,6 +281,7 @@ def get_all_pages(service,
soft_errors=False,
throw_reasons=None,
retry_reasons=None,
page_args_in_body=False,
**kwargs):
"""Aggregates and returns all pages of a Google service function response.
@@ -311,15 +312,22 @@ def get_all_pages(service,
retry_reasons: A list of Google HTTP error reason strings indicating which
error should be retried, using exponential backoff techniques, when the
error reason is encountered.
page_args_in_body: Some APIs like Chrome Policy want pageToken and pageSize
in the body.
**kwargs: Additional params to pass to the request method.
Returns:
A list of all items received from all paged responses.
"""
if 'maxResults' not in kwargs and 'pageSize' not in kwargs:
if page_args_in_body:
kwargs.setdefault('body', {})
if 'maxResults' not in kwargs and 'pageSize' not in kwargs and 'pageSize' not in kwargs.get('body', {}):
page_key = _get_max_page_size_for_api_call(service, function, **kwargs)
if page_key:
kwargs.update(page_key)
if page_args_in_body:
kwargs['body'].update(page_key)
else:
kwargs.update(page_key)
all_items = []
page_token = None
total_items = 0
@@ -334,7 +342,10 @@ def get_all_pages(service,
if not page_token:
finalize_page_message(page_message)
return all_items
kwargs['pageToken'] = page_token
if page_args_in_body:
kwargs['body']['pageToken'] = page_token
else:
kwargs['pageToken'] = page_token
# TODO: Make this private once all execution related items that use this method

View File

@@ -8,6 +8,7 @@ from unittest.mock import patch
from gam import SetGlobalVariables
import gam.gapi as gapi
from gam.gapi import errors
import httplib2
def create_http_error(status, reason, message):
@@ -21,10 +22,10 @@ def create_http_error(status, reason, message):
Returns:
googleapiclient.errors.HttpError
"""
response = {
response = httplib2.Response({
'status': status,
'content-type': 'application/json',
}
})
content = {
'error': {
'code': status,

207
src/gam/gapi/chat.py Normal file
View File

@@ -0,0 +1,207 @@
import sys
import googleapiclient.errors
import gam
from gam.var import *
from gam import controlflow
from gam import display
from gam import fileutils
from gam import gapi
from gam import utils
from gam.gapi import errors as gapi_errors
# Chat scope isn't in discovery doc so need to manually set
CHAT_SCOPES = ['https://www.googleapis.com/auth/chat.bot']
def build():
return gam.buildGAPIServiceObject('chat',
act_as=None,
scopes=CHAT_SCOPES)
THROW_REASONS = [
gapi_errors.ErrorReason.FOUR_O_FOUR, # Chat API not configured
]
def _chat_error_handler(chat, err):
if err.status_code == 404:
project_id = chat._http.credentials.project_id
url = f'https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat?project={project_id}'
print('ERROR: you need to configure Google Chat for your API project. Please go to:')
print()
print(f' {url}')
print()
print('and complete all fields.')
else:
raise err
sys.exit(1)
def print_spaces():
chat = build()
todrive = False
i =3
while i < len(sys.argv):
myarg = sys.argv[i].lower()
if myarg == 'todrive':
todrive = True
i += 1
else:
controlflow.invalid_argument_exit(myarg, 'gam print chatspaces')
try:
spaces = gapi.get_all_pages(chat.spaces(), 'list', 'spaces', throw_reasons=THROW_REASONS)
except googleapiclient.errors.HttpError as err:
_chat_error_handler(chat, err)
if not spaces:
print('Bot not added to any Chat rooms or users yet.')
else:
display.write_csv_file(spaces, spaces[0].keys(), 'Chat Spaces', todrive)
def print_members():
chat = build()
space = None
todrive = False
i = 3
while i < len(sys.argv):
myarg = sys.argv[i].lower()
if myarg == 'space':
space = sys.argv[i+1]
if space[:7] != 'spaces/':
space = f'spaces/{space}'
i += 2
elif myarg == 'todrive':
todrive = True
i += 1
else:
controlflow.invalid_argument_exit(myarg, "gam print chatmembers")
if not space:
controlflow.system_error_exit(2,
'space <ChatSpace> is required.')
try:
results = gapi.get_all_pages(chat.spaces().members(), 'list', 'memberships', parent=space)
except googleapiclient.errors.HttpError as err:
_chat_error_handler(chat, err)
members = []
titles = []
for result in results:
member = utils.flatten_json(result)
for key in member:
if key not in titles:
titles.append(key)
members.append(utils.flatten_json(result))
display.write_csv_file(members, titles, 'Chat Members', todrive)
def create_message():
chat = build()
body = {}
i = 3
while i < len(sys.argv):
myarg = sys.argv[i].lower()
if myarg == 'text':
body['text'] = sys.argv[i+1].replace('\\r', '\r').replace('\\n', '\n')
i += 2
elif myarg == 'textfile':
filename = sys.argv[i + 1]
i, encoding = gam.getCharSet(i + 2)
body['text'] = fileutils.read_file(filename, encoding=encoding)
elif myarg == 'space':
space = sys.argv[i+1]
if space[:7] != 'spaces/':
space = f'spaces/{space}'
i += 2
elif myarg == 'thread':
body['thread'] = {'name': sys.argv[i+1]}
i += 2
else:
controlflow.invalid_argument_exit(myarg, "gam create chat")
if not space:
controlflow.system_error_exit(2,
'space <ChatSpace> is required.')
if 'text' not in body:
controlflow.system_error_exit(2,
'text <String> or textfile <FileName> is required.')
if len(body['text']) > 4096:
body['text'] = body['text'][:4095]
print('WARNING: trimmed message longer than 4k to be 4k in length.')
try:
resp = gapi.call(chat.spaces().messages(),
'create',
parent=space,
body=body,
throw_reasons=THROW_REASONS)
except googleapiclient.errors.HttpError as err:
_chat_error_handler(chat, err)
if 'thread' in body:
print(f'responded to thread {resp["thread"]["name"]}')
else:
print(f'started new thread {resp["thread"]["name"]}')
print(f'message {resp["name"]}')
def delete_message():
chat = build()
name = None
i = 3
while i < len(sys.argv):
myarg = sys.argv[i].lower()
if myarg == 'name':
name = sys.argv[i+1]
i += 2
else:
controlflow.invalid_argument_exit(myarg, "gam delete chat")
if not name:
controlflow.system_error_exit(2,
'name <String> is required.')
try:
gapi.call(chat.spaces().messages(),
'delete',
name=name)
except googleapiclient.errors.HttpError as err:
_chat_error_handler(chat, err)
def update_message():
chat = build()
body = {}
name = None
updateMask = 'text'
i = 3
while i < len(sys.argv):
myarg = sys.argv[i].lower()
if myarg == 'text':
body['text'] = sys.argv[i+1].replace('\\r', '\r').replace('\\n', '\n')
i += 2
elif myarg == 'textfile':
filename = sys.argv[i + 1]
i, encoding = gam.getCharSet(i + 2)
body['text'] = fileutils.read_file(filename, encoding=encoding)
elif myarg == 'name':
name = sys.argv[i+1]
i += 2
else:
controlflow.invalid_argument_exit(myarg, "gam update chat")
if not name:
controlflow.system_error_exit(2,
'name <String> is required.')
if 'text' not in body:
controlflow.system_error_exit(2,
'text <String> or textfile <FileName> is required.')
if len(body['text']) > 4096:
body['text'] = body['text'][:4095]
print('WARNING: trimmed message longer than 4k to be 4k in length.')
try:
resp = gapi.call(chat.spaces().messages(),
'update',
name=name,
updateMask=updateMask,
body=body)
except googleapiclient.errors.HttpError as err:
_chat_error_handler(chat, err)
if 'thread' in body:
print(f'updated response to thread {resp["thread"]["name"]}')
else:
print(f'updated message on thread {resp["thread"]["name"]}')
print(f'message {resp["name"]}')

View File

@@ -39,6 +39,8 @@ def printshow_policies():
orgunit = None
printer_id = None
app_id = None
body = {}
namespaces = []
i = 3
while i < len(sys.argv):
myarg = sys.argv[i].lower().replace('_', '')
@@ -51,59 +53,85 @@ def printshow_policies():
elif myarg == 'appid':
app_id = sys.argv[i+1]
i += 2
elif myarg == 'namespace':
namespaces.extend(sys.argv[i+1].replace(',', ' ').split())
i += 2
else:
msg = f'{myarg} is not a valid argument to "gam print chromepolicy"'
controlflow.system_error_exit(3, msg)
if not orgunit:
controlflow.system_error_exit(3, 'You must specify an orgunit')
body = {
'policyTargetKey': {
'targetResource': orgunit,
}
}
body['policyTargetKey'] = {'targetResource': orgunit}
if printer_id:
body['policyTargetKey']['additionalTargetKeys'] = {'printer_id': printer_id}
namespaces = ['chrome.printers']
if not namespaces:
namespaces = ['chrome.printers']
elif app_id:
body['policyTargetKey']['additionalTargetKeys'] = {'app_id': app_id}
namespaces = ['chrome.users.apps',
'chrome.devices.managedGuest.apps',
'chrome.devices.kiosk.apps']
else:
if not namespaces:
namespaces = ['chrome.users.apps',
'chrome.devices.managedGuest.apps',
'chrome.devices.kiosk.apps']
elif not namespaces:
namespaces = [
'chrome.users',
# Not yet implemented:
# 'chrome.devices',
# 'chrome.devices.managedGuest',
# 'chrome.devices.kiosk',
'chrome.users.apps',
'chrome.devices',
'chrome.devices.kiosk',
'chrome.devices.managedGuest',
]
throw_reasons = [gapi_errors.ErrorReason.FOUR_O_O,]
orgunitPath = gapi_directory_orgunits.orgunit_from_orgunitid(orgunit[9:], None)
header = f'Organizational Unit: {orgunitPath}'
if printer_id:
header += f', printerid: {printer_id}'
elif app_id:
header += f', appid: {app_id}'
print(header)
print(f'Organizational Unit: {orgunitPath}')
for namespace in namespaces:
spacing = ' '
body['policySchemaFilter'] = f'{namespace}.*'
try:
policies = gapi.get_all_pages(svc.customers().policies(), 'resolve',
items='resolvedPolicies',
throw_reasons=throw_reasons,
customer=customer,
body=body)
body=body,
page_args_in_body=True)
except googleapiclient.errors.HttpError:
policies = []
for policy in sorted(policies, key=lambda k: k.get('value', {}).get('policySchema', '')):
# sort policies first by app/printer id then by schema name
policies = sorted(policies,
key=lambda k: (
list(k.get('targetKey', {}).get('additionalTargetKeys', {}).values()),
k.get('value', {}).get('policySchema', '')))
printed_ids = []
for policy in policies:
print()
name = policy.get('value', {}).get('policySchema', '')
print(name)
for key, val in policy['targetKey'].get('additionalTargetKeys', {}).items():
additional_id = f'{key} - {val}'
if additional_id not in printed_ids:
print(f' {additional_id}')
printed_ids.append(additional_id)
spacing = ' '
print(f'{spacing}{name}')
values = policy.get('value', {}).get('value', {})
for setting, value in values.items():
if isinstance(value, str) and value.find('_ENUM_') != -1:
# Handle TYPE_MESSAGE fields with durations, values, counts and timeOfDay as special cases
schema = CHROME_SCHEMA_TYPE_MESSAGE.get(name, {}).get(setting.lower())
if schema and setting == schema['casedField']:
vtype = schema['type']
if vtype in {'duration', 'value'}:
value = value.get(vtype, '')
if value:
if value.endswith('s'):
value = value[:-1]
value = int(value) // schema['scale']
elif vtype == 'count':
pass
else: ##timeOfDay
hours = value.get(vtype, {}).get('hours', 0)
minutes = value.get(vtype, {}).get('minutes', 0)
value = f'{hours:02}:{minutes:02}'
elif isinstance(value, str) and value.find('_ENUM_') != -1:
value = value.split('_ENUM_')[-1]
print(f' {setting}: {value}')
print(f'{spacing}{setting}: {value}')
def build_schemas(svc=None, sfilter=None):
@@ -245,6 +273,49 @@ def delete_policy():
gapi.call(svc.customers().policies().orgunits(), 'batchInherit', customer=customer, body=body)
CHROME_SCHEMA_TYPE_MESSAGE = {
'chrome.users.AutoUpdateCheckPeriodNew': {
'autoupdatecheckperiodminutesnew':
{'casedField': 'autoUpdateCheckPeriodMinutesNew',
'type': 'duration', 'minVal': 1, 'maxVal': 720, 'scale': 60}},
'chrome.users.BrowserSwitcherDelayDuration':
{'browserswitcherdelayduration':
{'casedField': 'browserSwitcherDelayDuration',
'type': 'duration', 'minVal': 0, 'maxVal': 30, 'scale': 1}},
'chrome.users.FetchKeepaliveDurationSecondsOnShutdown':
{'fetchkeepalivedurationsecondsonshutdown':
{'casedField': 'fetchKeepaliveDurationSecondsOnShutdown',
'type': 'duration', 'minVal': 0, 'maxVal': 5, 'scale': 1}},
'chrome.users.MaxInvalidationFetchDelay':
{'maxinvalidationfetchdelay':
{'casedField': 'maxInvalidationFetchDelay',
'type': 'duration', 'minVal': 1, 'maxVal': 30, 'scale': 1, 'default': 10}},
'chrome.users.PrintingMaxSheetsAllowed':
{'printingmaxsheetsallowednullable':
{'casedField': 'printingMaxSheetsAllowedNullable',
'type': 'value', 'minVal': 1, 'maxVal': None, 'scale': 1}},
'chrome.users.PrintJobHistoryExpirationPeriodNew':
{'printjobhistoryexpirationperioddaysnew':
{'casedField': 'printJobHistoryExpirationPeriodDaysNew',
'type': 'duration', 'minVal': -1, 'maxVal': None, 'scale': 86400}},
'chrome.users.SecurityTokenSessionSettings':
{'securitytokensessionnotificationseconds':
{'casedField': 'securityTokenSessionNotificationSeconds',
'type': 'duration', 'minVal': 0, 'maxVal': 9999, 'scale': 1}},
'chrome.users.SessionLength':
{'sessiondurationlimit':
{'casedField': 'sessionDurationLimit',
'type': 'duration', 'minVal': 1, 'maxVal': 1440, 'scale': 60}},
'chrome.users.UpdatesSuppressed':
{'updatessuppresseddurationmin':
{'casedField': 'updatesSuppressedDurationMin',
'type': 'count', 'minVal': 1, 'maxVal': 1440, 'scale': 1},
'updatessuppressedstarttime':
{'casedField': 'updatesSuppressedStartTime',
'type': 'timeOfDay'}},
}
def update_policy():
svc = build()
customer = _get_customerid()
@@ -266,7 +337,8 @@ def update_policy():
app_id = sys.argv[i+1]
i += 2
elif myarg in schemas:
body['requests'].append({'policyValue': {'policySchema': schemas[myarg]['name'],
schemaName = schemas[myarg]['name']
body['requests'].append({'policyValue': {'policySchema': schemaName,
'value': {}},
'updateMask': ''})
i += 1
@@ -274,6 +346,39 @@ def update_policy():
field = sys.argv[i].lower()
if field in ['ou', 'org', 'orgunit', 'printerid', 'appid'] or '.' in field:
break # field is actually a new policy, orgunit or app/printer id
# Handle TYPE_MESSAGE fields with durations, values, counts and timeOfDay as special cases
schema = CHROME_SCHEMA_TYPE_MESSAGE.get(schemaName, {}).get(field)
if schema:
i += 1
casedField = schema['casedField']
vtype = schema['type']
if vtype != 'timeOfDay':
if 'default' not in schema:
value = gam.getInteger(sys.argv[i], casedField,
minVal=schema['minVal'], maxVal=schema['maxVal'])*schema['scale']
i += 1
elif i < len(sys.argv) and sys.argv[i].isdigit():
value = gam.getInteger(sys.argv[i], casedField,
minVal=schema['minVal'], maxVal=schema['maxVal'])*schema['scale']
i += 1
else: # Handle empty value for fields with default
value = schema['default']*schema['scale']
if i < len(sys.argv) and not sys.argv[i]:
i += 1
else:
value = utils.get_hhmm(sys.argv[i])
i += 1
if vtype == 'duration':
body['requests'][-1]['policyValue']['value'][casedField] = {vtype: f'{value}s'}
elif vtype == 'value':
body['requests'][-1]['policyValue']['value'][casedField] = {vtype: value}
elif vtype == 'count':
body['requests'][-1]['policyValue']['value'][casedField] = value
else: ##timeOfDay
hours, minutes = value.split(':')
body['requests'][-1]['policyValue']['value'][casedField] = {vtype: {'hours': hours, 'minutes': minutes}}
body['requests'][-1]['updateMask'] += f'{casedField},'
continue
expected_fields = ', '.join(schemas[myarg]['settings'])
if field not in expected_fields:
msg = f'Expected {myarg} field of {expected_fields}. Got {field}.'
@@ -290,14 +395,17 @@ def update_policy():
value = gam.getBoolean(value, field)
elif vtype in ['TYPE_ENUM']:
value = value.upper()
prefix = schemas[myarg]['settings'][field]['enum_prefix']
enum_values = schemas[myarg]['settings'][field]['enums']
if value not in enum_values:
if value in enum_values:
value = f'{prefix}{value}'
elif value.replace(prefix, '') in enum_values:
pass
else:
expected_enums = ', '.join(enum_values)
msg = f'Expected {myarg} {field} value to be one of ' \
f'{expected_enums}, got {value}'
controlflow.system_error_exit(8, msg)
prefix = schemas[myarg]['settings'][field]['enum_prefix']
value = f'{prefix}{value}'
elif vtype in ['TYPE_LIST']:
value = value.split(',')
if myarg == 'chrome.users.chromebrowserupdates' and \

View File

@@ -14,7 +14,7 @@ from gam.gapi.directory import customer as gapi_directory_customer
def create():
ci = gapi_cloudidentity.build('cloudidentity_beta')
ci = gapi_cloudidentity.build()
initialGroupConfig = 'EMPTY'
gapi_directory_customer.setTrueCustomerId()
parent = f'customers/{GC_Values[GC_CUSTOMER_ID]}'
@@ -44,7 +44,6 @@ def create():
body['additionalGroupKeys'].append({'id': alias})
i += 2
elif myarg in ['dynamic']:
# As of 2020/06/25 this doesn't work (yet?)
body['dynamicGroupMetadata'] = {
'queries': [{
'query': sys.argv[i + 1],
@@ -66,7 +65,7 @@ def create():
def delete():
ci = gapi_cloudidentity.build('cloudidentity_beta')
ci = gapi_cloudidentity.build()
group = sys.argv[3]
name = group_email_to_id(ci, group)
print(f'Deleting group {group}')
@@ -79,6 +78,7 @@ def info():
getUsers = True
showJoinDate = True
showUpdateDate = False
showMemberTree = False
i = 4
while i < len(sys.argv):
myarg = sys.argv[i].lower().replace('_', '')
@@ -91,12 +91,15 @@ def info():
elif myarg == 'showupdatedate':
showUpdateDate = True
i += 1
elif myarg == 'membertree':
showMemberTree = True
i += 1
else:
controlflow.invalid_argument_exit(myarg, 'gam info cigroup')
name = group_email_to_id(ci, group)
basic_info = gapi.call(ci.groups(), 'get', name=name)
display.print_json(basic_info)
if getUsers:
if getUsers and not showMemberTree:
if not showJoinDate and not showUpdateDate:
view = 'BASIC'
pageSize = 1000
@@ -110,10 +113,11 @@ def info():
fields='*',
pageSize=pageSize,
view=view)
print('Members:')
print(' Members:')
for member in members:
role = get_single_role(member.get('roles', [])).lower()
email = member.get('memberKey', {}).get('id')
member_type = member.get('type', 'USER').lower()
jc_string = ''
if showJoinDate:
joined = member.get('createTime', 'Unknown')
@@ -121,15 +125,39 @@ def info():
if showUpdateDate:
updated = member.get('updateTime', 'Unknown')
jc_string += f' updated {updated}'
print(
f'{role}: {email}{jc_string}'
# f' {member.get("role", ROLE_MEMBER).lower()}: {member.get("email", member["id"])} ({member["type"].lower()})'
)
print(f' {role}: {email} ({member_type}){jc_string}')
print(f'Total {len(members)} users in group')
elif showMemberTree:
print(' Membership Tree:')
cached_group_members = {}
print_member_tree(ci, name, cached_group_members, 2, True)
def print_member_tree(ci, group_id, cached_group_members, spaces, show_role):
if not group_id in cached_group_members:
cached_group_members[group_id] = gapi.get_all_pages(ci.groups().memberships(),
'list',
'memberships',
parent=group_id,
view='FULL',
fields='*',
pageSize=1000)
for member in cached_group_members[group_id]:
member_id = member.get('name', '')
member_id = member_id.split('/')[-1]
email = member.get('memberKey', {}).get('id')
member_type = member.get('type', 'USER').lower()
if show_role:
role = get_single_role(member.get('roles', [])).lower()
print(f'{" " * spaces}{role}: {email} ({member_type})')
else:
print(f'{" " * spaces}{email} ({member_type})')
if member_type == 'group':
print_member_tree(ci, f'groups/{member_id}', cached_group_members, spaces + 2, False)
def info_member():
ci = gapi_cloudidentity.build('cloudidentity_beta')
ci = gapi_cloudidentity.build()
member = gam.normalizeEmailAddressOrUID(sys.argv[3])
group = gam.normalizeEmailAddressOrUID(sys.argv[4])
group_name = gapi.call(ci.groups(),
@@ -247,7 +275,7 @@ def print_():
except googleapiclient.errors.HttpError:
controlflow.system_error_exit(
2,
f'enterprisemember requires Enterprise license')
'enterprisemember requires Enterprise license')
entityList = []
for entity in result:
if entity['relationType'] == 'DIRECT':
@@ -302,7 +330,7 @@ def print_():
ownersCount = 0
for member in groupMembers:
member_email = member['memberKey']['id']
role = get_single_role(member.get('roles'))
role = get_single_role(member.get('roles', []))
if not validRoles or role in validRoles:
if role == ROLE_MEMBER:
if members:
@@ -343,6 +371,56 @@ def print_():
display.write_csv_file(csvRows, titles, 'Groups', todrive)
def _get_groups_list(ci=None, member=None, parent=None):
if not ci:
ci = gapi_cloudidentity.build()
if not parent:
gapi_directory_customer.setTrueCustomerId()
parent = f'customers/{GC_Values[GC_CUSTOMER_ID]}'
gam.printGettingAllItems('Groups', member)
page_message = gapi.got_total_items_first_last_msg('Groups')
if member:
fields = 'nextPageToken,memberships(groupKey(id),relationType)'
try:
groups_to_get = gapi.get_all_pages(ci.groups().memberships(),
'searchTransitiveGroups',
'memberships',
throw_reasons=[gapi_errors.ErrorReason.FOUR_O_O],
message_attribute=['groupKey', 'id'],
page_message=page_message,
parent='groups/-',
query=member,
pageSize=1000,
fields=fields)
except googleapiclient.errors.HttpError:
controlflow.system_error_exit(
2,
'enterprisemember requires Enterprise license')
return [group['groupKey']['id'] for group in groups_to_get if group['relationType'] == 'DIRECT']
else:
groups_to_get = gapi.get_all_pages(
ci.groups(),
'list',
'groups',
message_attribute=['groupKey', 'id'],
page_message=page_message,
parent=parent,
view='BASIC',
pageSize=1000,
fields='nextPageToken,groups(groupKey(id))')
return [group['groupKey']['id'] for group in groups_to_get]
def get_membership_graph(member):
ci = gapi_cloudidentity.build('cloudidentity_beta')
query = f"member_key_id == '{member}' && 'cloudidentity.googleapis.com/groups.discussion_forum' in labels"
result = gapi.call(ci.groups().memberships(),
'getMembershipGraph',
parent='groups/-',
query=query)
return result.get('response')
def print_members():
ci = gapi_cloudidentity.build('cloudidentity_beta')
todrive = False
@@ -381,36 +459,7 @@ def print_members():
controlflow.invalid_argument_exit(sys.argv[i],
'gam print cigroup-members')
if not groups_to_get:
gam.printGettingAllItems('Groups', usemember)
page_message = gapi.got_total_items_first_last_msg('Groups')
if usemember:
try:
groups_to_get = gapi.get_all_pages(ci.groups().memberships(),
'searchTransitiveGroups',
'memberships',
throw_reasons=[gapi_errors.ErrorReason.FOUR_O_O],
message_attribute=['groupKey', 'id'],
page_message=page_message,
parent='groups/-', query=usemember,
pageSize=1000,
fields='nextPageToken,memberships(groupKey(id),relationType)')
except googleapiclient.errors.HttpError:
controlflow.system_error_exit(
2,
f'enterprisemember requires Enterprise license')
groups_to_get = [group['groupKey']['id'] for group in groups_to_get if group['relationType'] == 'DIRECT']
else:
groups_to_get = gapi.get_all_pages(
ci.groups(),
'list',
'groups',
message_attribute=['groupKey', 'id'],
page_message=page_message,
parent=parent,
view='BASIC',
pageSize=1000,
fields='nextPageToken,groups(groupKey(id))')
groups_to_get = [group['groupKey']['id'] for group in groups_to_get]
groups_to_get = _get_groups_list(ci, usemember, parent)
i = 0
count = len(groups_to_get)
for group_email in groups_to_get:
@@ -773,6 +822,14 @@ def update():
'cloudidentity.googleapis.com/groups.discussion_forum': ''
}
i += 1
elif myarg in ['dynamic']:
body['dynamicGroupMetadata'] = {
'queries': [{
'query': sys.argv[i + 1],
'resourceType': 'USER'
}]
}
i += 2
else:
controlflow.invalid_argument_exit(sys.argv[i],
'gam update cigroup')

View File

@@ -3,6 +3,7 @@ from time import sleep
import gam
from gam import gapi
from gam.gapi import directory as gapi_directory
from gam.gapi import errors as gapi_errors
def get_primary(email):
@@ -53,10 +54,16 @@ def wait_for_mailbox(users):
i += 1
user = gam.normalizeEmailAddressOrUID(user)
while True:
result = gapi.call(cd.users(),
'get',
'fields=isMailboxSetup',
userKey=user)
try:
result = gapi.call(cd.users(),
'get',
'fields=isMailboxSetup',
userKey=user,
throw_reasons=[gapi_errors.ErrorReason.USER_NOT_FOUND])
except gapi_errors.GapiUserNotFoundError:
print(f'{user} mailboxIsSetup: False (user does not exist yet)')
sleep(3)
continue
mailbox_is_setup = result.get('isMailboxSetup')
print(f'{user} mailboxIsSetup: {mailbox_is_setup}')
if mailbox_is_setup:

View File

@@ -119,6 +119,7 @@ class ErrorReason(Enum):
FIVE_O_THREE = '503'
FOUR_O_NINE = '409'
FOUR_O_O = '400'
FOUR_O_FOUR = '404'
FOUR_O_THREE = '403'
FOUR_TWO_NINE = '429'
GATEWAY_TIMEOUT = 'gatewayTimeout'

View File

@@ -7,6 +7,7 @@ from unittest.mock import patch
import googleapiclient.errors
from gam.gapi import errors
import httplib2
def create_simple_http_error(status, reason, message):
@@ -15,10 +16,10 @@ def create_simple_http_error(status, reason, message):
def create_http_error(status, content):
response = {
response = httplib2.Response({
'status': status,
'content-type': 'application/json',
}
})
content_as_bytes = json.dumps(content).encode('UTF-8')
return googleapiclient.errors.HttpError(response, content_as_bytes)
@@ -73,6 +74,7 @@ class ErrorsTest(unittest.TestCase):
def test_get_gapi_error_extracts_user_not_found(self):
err = create_simple_http_error(404, 'notFound',
'Resource Not Found: userKey.')
print(err)
http_status, reason, message = errors.get_gapi_error_detail(err)
self.assertEqual(http_status, 404)
self.assertEqual(reason, errors.ErrorReason.USER_NOT_FOUND.value)
@@ -158,7 +160,7 @@ class ErrorsTest(unittest.TestCase):
def test_get_gapi_error_extracts_single_error_with_message(self):
status_code = 999
response = {'status': status_code}
response = httplib2.Response({'status': status_code})
# This error does not have an "errors" key describing each error.
content = {'error': {'code': status_code, 'message': 'unknown error'}}
content_as_bytes = json.dumps(content).encode('UTF-8')
@@ -172,7 +174,7 @@ class ErrorsTest(unittest.TestCase):
def test_get_gapi_error_exits_code_4_on_malformed_error_with_unknown_description(
self):
status_code = 999
response = {'status': status_code}
response = httplib2.Response({'status': status_code})
# This error only has an error_description_field and an unknown description.
content = {'error_description': 'something errored'}
content_as_bytes = json.dumps(content).encode('UTF-8')
@@ -184,7 +186,7 @@ class ErrorsTest(unittest.TestCase):
def test_get_gapi_error_exits_on_invalid_error_description(self):
status_code = 400
response = {'status': status_code}
response = httplib2.Response({'status': status_code})
content = {'error_description': 'Invalid Value'}
content_as_bytes = json.dumps(content).encode('UTF-8')
err = googleapiclient.errors.HttpError(response, content_as_bytes)
@@ -196,7 +198,7 @@ class ErrorsTest(unittest.TestCase):
def test_get_gapi_error_exits_code_4_on_unexpected_error_contents(self):
status_code = 900
response = {'status': status_code}
response = httplib2.Response({'status': status_code})
content = {'notErrorContentThatIsExpected': 'foo'}
content_as_bytes = json.dumps(content).encode('UTF-8')
err = googleapiclient.errors.HttpError(response, content_as_bytes)

View File

@@ -285,7 +285,7 @@ def showReport():
customerId = GC_Values[GC_CUSTOMER_ID]
if customerId == MY_CUSTOMER:
customerId = None
filters = parameters = actorIpAddress = startTime = endTime = eventName = orgUnitId = None
filters = parameters = actorIpAddress = groupIdFilter = startTime = endTime = eventName = orgUnitId = None
tryDate = datetime.date.today().strftime(YYYYMMDD_FORMAT)
to_drive = False
userKey = 'all'
@@ -330,6 +330,9 @@ def showReport():
elif myarg == 'ip':
actorIpAddress = sys.argv[i + 1]
i += 2
elif myarg == 'groupidfilter':
groupIdFilter = sys.argv[i + 1]
i += 2
elif myarg == 'todrive':
to_drive = True
i += 1
@@ -489,7 +492,8 @@ def showReport():
endTime=endTime,
eventName=eventName,
filters=filters,
orgUnitID=orgUnitId)
orgUnitID=orgUnitId,
groupIdFilter=groupIdFilter)
if activities:
titles = ['name']
csvRows = []

View File

@@ -254,6 +254,18 @@ def get_delta_time(argstr):
return deltaTime
def get_hhmm(argstr):
argstr = argstr.strip()
if argstr:
try:
dateTime = datetime.datetime.strptime(argstr, HHMM_FORMAT)
return argstr
except ValueError:
controlflow.system_error_exit(
2, f'expected a <{HHMM_FORMAT_REQUIRED}>; got {argstr}')
controlflow.system_error_exit(2, f'expected a <{HHMM_FORMAT_REQUIRED}>')
def get_yyyymmdd(argstr, minLen=1, returnTimeStamp=False, returnDateTime=False):
argstr = argstr.strip()
if argstr:

View File

@@ -8,7 +8,7 @@ import platform
import re
GAM_AUTHOR = 'Jay Lee <jay0lee@gmail.com>'
GAM_VERSION = '6.01'
GAM_VERSION = '6.07'
GAM_LICENSE = 'Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)'
GAM_URL = 'https://git.io/gam'
@@ -56,6 +56,11 @@ SKUS = {
'aliases': ['identitypremium', 'cloudidentitypremium'],
'displayName': 'Cloud Identity Premium'
},
'1010350001': {
'product': '101035',
'aliases': ['cloudsearch'],
'displayName': 'Google Cloud Search',
},
'1010310002': {
'product': '101031',
'aliases': ['gsefe', 'e4e', 'gsuiteenterpriseeducation'],
@@ -271,6 +276,7 @@ PRODUCTID_NAME_MAPPINGS = {
'101031': 'G Suite Workspace for Education',
'101033': 'Google Voice',
'101034': 'G Suite Archived',
'101035': 'Cloud Search',
'101037': 'G Suite Workspace for Education',
'Google-Apps': 'Google Workspace',
'Google-Chrome-Device-Management': 'Google Chrome Device Management',
@@ -280,12 +286,8 @@ PRODUCTID_NAME_MAPPINGS = {
# Legacy APIs that use v1 discovery. Newer APIs should all use v2.
V1_DISCOVERY_APIS = {
'admin',
'calendar',
'drive',
'oauth2',
'reseller',
'siteVerification',
}
API_NAME_MAPPING = {
@@ -293,7 +295,7 @@ API_NAME_MAPPING = {
'reports': 'admin',
'datatransfer': 'admin',
'drive3': 'drive',
'cloudresourcemanagerv1': 'cloudresourcemanager',
'calendar': 'calendar-json',
'cloudidentity_beta': 'cloudidentity',
}
@@ -307,8 +309,7 @@ API_VER_MAPPING = {
'classroom': 'v1',
'cloudidentity': 'v1',
'cloudidentity_beta': 'v1beta1',
'cloudresourcemanager': 'v2',
'cloudresourcemanagerv1': 'v1',
'cloudresourcemanager': 'v3',
'contactdelegation': 'v1',
'datatransfer': 'datatransfer_v1',
'directory': 'directory_v1',
@@ -472,6 +473,7 @@ DRIVEFILE_FIELDS_CHOICES_MAP = {
'lastviewedbymedate': 'lastViewedByMeDate',
'lastviewedbymetime': 'lastViewedByMeDate',
'lastviewedbyuser': 'lastViewedByMeDate',
'linksharemetadata': 'linkShareMetadata',
'md5': 'md5Checksum',
'md5checksum': 'md5Checksum',
'md5sum': 'md5Checksum',
@@ -490,6 +492,7 @@ DRIVEFILE_FIELDS_CHOICES_MAP = {
'owners': 'owners',
'parents': 'parents',
'permissions': 'permissions',
'resourcekey': 'resourceKey',
'quotabytesused': 'quotaBytesUsed',
'quotaused': 'quotaBytesUsed',
'shareable': 'shareable',
@@ -888,7 +891,6 @@ RT_TAG_REPLACE_PATTERN = re.compile(r'{(.*?)}')
LOWERNUMERIC_CHARS = string.ascii_lowercase + string.digits
ALPHANUMERIC_CHARS = LOWERNUMERIC_CHARS + string.ascii_uppercase
URL_SAFE_CHARS = ALPHANUMERIC_CHARS + '-._~'
PASSWORD_SAFE_CHARS = ALPHANUMERIC_CHARS + string.punctuation + ' '
FILENAME_SAFE_CHARS = ALPHANUMERIC_CHARS + '-_.() '
FILTER_ADD_LABEL_TO_ARGUMENT_MAP = {
@@ -1514,7 +1516,7 @@ USER_EXTERNALID_TYPES = [
]
USER_GENDER_TYPES = ['female', 'male', 'unknown']
USER_IM_TYPES = ['home', 'work', 'other']
USER_KEYWORD_TYPES = ['occupation', 'outlook']
USER_KEYWORD_TYPES = ['occupation', 'outlook', 'mission']
USER_LOCATION_TYPES = ['default', 'desk']
USER_ORGANIZATION_TYPES = ['domain_only', 'school', 'unknown', 'work']
USER_PHONE_TYPES = [
@@ -1529,7 +1531,7 @@ USER_RELATION_TYPES = [
]
USER_WEBSITE_TYPES = [
'app_install_page', 'blog', 'ftp', 'home', 'home_page', 'other', 'profile',
'reservations', 'work'
'reservations', 'resume', 'work'
]
WEBCOLOR_MAP = {
@@ -1931,6 +1933,9 @@ DELTA_DATE_FORMAT_REQUIRED = '(+|-)<Number>(d|w|y)'
DELTA_TIME_PATTERN = re.compile(r'^([+-])(\d+)([mhdwy])$')
DELTA_TIME_FORMAT_REQUIRED = '(+|-)<Number>(m|h|d|w|y)'
HHMM_FORMAT = '%H:%M'
HHMM_FORMAT_REQUIRED = 'hh:mm'
YYYYMMDD_FORMAT = '%Y-%m-%d'
YYYYMMDD_FORMAT_REQUIRED = 'yyyy-mm-dd'

View File

@@ -1,11 +1,12 @@
cryptography
distro; sys_platform == 'linux'
filelock
google-api-python-client==2.0.2
google-api-python-client>=2.1
google-auth-httplib2
google-auth-oauthlib>=0.4.1
google-auth>=1.11.2
httplib2>=0.17.0
importlib.metadata; python_version < '3.8'
passlib>=1.7.2
python-dateutil
yubikey-manager>=4.0.0