From ccee0bd6698b6f23bc849667680e81ea5b252170 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Thu, 24 Sep 2020 16:33:40 +0800 Subject: [PATCH] feat: add optional google drive permissions sync --- Pipfile | 2 + Pipfile.lock | 171 +++++++++++++++++++++++- app/controller/command/commands/team.py | 54 +++++++- app/controller/command/parser.py | 11 +- app/model/team.py | 2 + config/__init__.py | 7 +- docs/Config.md | 9 ++ factory/__init__.py | 31 ++++- interface/gcp.py | 81 +++++++++++ mypy.ini | 6 + tests/app/model/team_test.py | 3 +- tests/config/config_test.py | 7 +- tests/interface/gcp_test.py | 48 +++++++ 13 files changed, 416 insertions(+), 16 deletions(-) create mode 100644 interface/gcp.py create mode 100644 tests/interface/gcp_test.py diff --git a/Pipfile b/Pipfile index 714b52c1..8a3df7e5 100644 --- a/Pipfile +++ b/Pipfile @@ -21,6 +21,8 @@ cryptography = "*" requests = "*" apscheduler = "*" watchtower = "==0.7.3" +google-api-python-client = "*" +google-auth-oauthlib = "*" [dev-packages] awscli = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 8846d1ed..fee281dd 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "01c9310553496516f9cd304db4aa0124241ab1b096fda51db137e0dd27581686" + "sha256": "c4c776705b5c306607d0415e7e46b7bbbe6720d093dd365c60242758e42ea3be" }, "pipfile-spec": 6, "requires": { @@ -73,6 +73,14 @@ ], "version": "==1.18.4" }, + "cachetools": { + "hashes": [ + "sha256:513d4ff98dd27f85743a8dc0e92f55ddb1b49e060c2d5961512855cda2c01a98", + "sha256:bbaa39c3dede00175df2dc2b03d0cf18dd2d32a7de7beb68072d13043c9edb20" + ], + "markers": "python_version ~= '3.5'", + "version": "==4.1.1" + }, "certifi": { "hashes": [ "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", @@ -205,6 +213,53 @@ "index": "pypi", "version": "==0.7.0" }, + "google-api-core": { + "hashes": [ + "sha256:67e33a852dcca7cb7eff49abc35c8cc2c0bb8ab11397dc8306d911505cae2990", + "sha256:779107f17e0fef8169c5239d56a8fbff03f9f72a3893c0c9e5842ec29dfedd54" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.22.2" + }, + "google-api-python-client": { + "hashes": [ + "sha256:05cb331ed1aa15746f606c7e36ea51dbe7c29b1a5df9bbf58140901fe23d7142", + "sha256:54a7d330833a2e7b0587446d7e4ae6d0244925a9a8e1dfe878f3f7e06cdedb62" + ], + "index": "pypi", + "version": "==1.12.2" + }, + "google-auth": { + "hashes": [ + "sha256:31941bf019fb242c04d0de32845da10180788bfddb0de87d78c4bdf55555dda1", + "sha256:873051a6317294b083795cffc467bcd05b6df483ef542bfe0069ddbfbac0a096" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.21.3" + }, + "google-auth-httplib2": { + "hashes": [ + "sha256:8d092cc60fb16517b12057ec0bba9185a96e3b7169d86ae12eae98e645b7bc39", + "sha256:aeaff501738b289717fac1980db9711d77908a6c227f60e4aa1923410b43e2ee" + ], + "version": "==0.0.4" + }, + "google-auth-oauthlib": { + "hashes": [ + "sha256:88d2cd115e3391eb85e1243ac6902e76e77c5fe438b7276b297fbe68015458dd", + "sha256:a92a0f6f41a0fb6138454fbc02674e64f89d82a244ea32f98471733c8ef0e0e1" + ], + "index": "pypi", + "version": "==0.4.1" + }, + "googleapis-common-protos": { + "hashes": [ + "sha256:560716c807117394da12cecb0a54da5a451b5cf9866f1d37e9a5e2329a665351", + "sha256:c8961760f5aad9a711d37b675be103e0cc4e9a39327e0d6d857872f698403e24" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.52.0" + }, "gunicorn": { "hashes": [ "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", @@ -213,6 +268,13 @@ "index": "pypi", "version": "==20.0.4" }, + "httplib2": { + "hashes": [ + "sha256:8af66c1c52c7ffe1aa5dc4bcd7c769885254b0756e6e69f953c7f0ab49a70ba3", + "sha256:ca2914b015b6247791c4866782fa6042f495b94401a0f0bd3e1d6e0ba2236782" + ], + "version": "==0.18.1" + }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", @@ -329,6 +391,14 @@ "markers": "python_version >= '3.5'", "version": "==4.7.6" }, + "oauthlib": { + "hashes": [ + "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", + "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==3.1.0" + }, "packaging": { "hashes": [ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", @@ -353,6 +423,29 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, + "protobuf": { + "hashes": [ + "sha256:0bba42f439bf45c0f600c3c5993666fcb88e8441d011fad80a11df6f324eef33", + "sha256:1e834076dfef9e585815757a2c7e4560c7ccc5962b9d09f831214c693a91b463", + "sha256:339c3a003e3c797bc84499fa32e0aac83c768e67b3de4a5d7a5a9aa3b0da634c", + "sha256:361acd76f0ad38c6e38f14d08775514fbd241316cce08deb2ce914c7dfa1184a", + "sha256:3dee442884a18c16d023e52e32dd34a8930a889e511af493f6dc7d4d9bf12e4f", + "sha256:4d1174c9ed303070ad59553f435846a2f877598f59f9afc1b89757bdf846f2a7", + "sha256:5db9d3e12b6ede5e601b8d8684a7f9d90581882925c96acf8495957b4f1b204b", + "sha256:6a82e0c8bb2bf58f606040cc5814e07715b2094caeba281e2e7d0b0e2e397db5", + "sha256:8c35bcbed1c0d29b127c886790e9d37e845ffc2725cc1db4bd06d70f4e8359f4", + "sha256:91c2d897da84c62816e2f473ece60ebfeab024a16c1751aaf31100127ccd93ec", + "sha256:9c2e63c1743cba12737169c447374fab3dfeb18111a460a8c1a000e35836b18c", + "sha256:9edfdc679a3669988ec55a989ff62449f670dfa7018df6ad7f04e8dbacb10630", + "sha256:c0c5ab9c4b1eac0a9b838f1e46038c3175a95b0f2d944385884af72876bd6bc7", + "sha256:c8abd7605185836f6f11f97b21200f8a864f9cb078a193fe3c9e235711d3ff1e", + "sha256:d69697acac76d9f250ab745b46c725edf3e98ac24763990b24d58c16c642947a", + "sha256:df3932e1834a64b46ebc262e951cd82c3cf0fa936a154f0a42231140d8237060", + "sha256:e7662437ca1e0c51b93cadb988f9b353fa6b8013c0385d63a70c8a77d84da5f9", + "sha256:f68eb9d03c7d84bd01c790948320b768de8559761897763731294e3bc316decb" + ], + "version": "==3.13.0" + }, "py": { "hashes": [ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", @@ -361,6 +454,42 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.9.0" }, + "pyasn1": { + "hashes": [ + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + ], + "version": "==0.4.8" + }, + "pyasn1-modules": { + "hashes": [ + "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", + "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", + "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", + "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", + "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", + "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", + "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", + "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", + "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", + "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", + "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", + "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", + "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" + ], + "version": "==0.2.8" + }, "pycparser": { "hashes": [ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", @@ -431,6 +560,22 @@ "index": "pypi", "version": "==2.24.0" }, + "requests-oauthlib": { + "hashes": [ + "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", + "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", + "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" + ], + "version": "==1.3.0" + }, + "rsa": { + "hashes": [ + "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", + "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" + ], + "markers": "python_version >= '3.5'", + "version": "==4.6" + }, "s3transfer": { "hashes": [ "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13", @@ -484,6 +629,14 @@ ], "version": "==2.1" }, + "uritemplate": { + "hashes": [ + "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", + "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==3.0.1" + }, "urllib3": { "hashes": [ "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", @@ -546,6 +699,14 @@ ], "version": "==0.7.12" }, + "appnope": { + "hashes": [ + "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0", + "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71" + ], + "markers": "sys_platform == 'darwin'", + "version": "==0.1.0" + }, "astroid": { "hashes": [ "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", @@ -1071,11 +1232,11 @@ }, "rsa": { "hashes": [ - "sha256:35c5b5f6675ac02120036d97cf96f1fde4d49670543db2822ba5015e21a18032", - "sha256:4d409f5a7d78530a4a2062574c7bd80311bc3af29b364e293aa9b03eea77714f" + "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", + "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==4.5" + "markers": "python_version >= '3.5'", + "version": "==4.6" }, "s3transfer": { "hashes": [ diff --git a/app/controller/command/commands/team.py b/app/controller/command/commands/team.py index fed16e8d..6cff9438 100644 --- a/app/controller/command/commands/team.py +++ b/app/controller/command/commands/team.py @@ -8,10 +8,11 @@ from db.utils import get_team_by_name from interface.github import GithubAPIException, GithubInterface from interface.slack import SlackAPIError +from interface.gcp import GCPInterface from config import Config from app.model import Team, User from utils.slack_parse import check_permissions -from typing import Any, List +from typing import Any, List, Optional class TeamCommand(Command): @@ -27,19 +28,22 @@ def __init__(self, config: Config, db_facade: DBFacade, gh: GithubInterface, - sc: Any): + sc: Any, + gcp: Optional[GCPInterface] = None): """ Initialize team command parser. :param db_facade: Given Dynamo_DB Facade :param gh: Given Github Interface :param sc: Given Slack Client Interface + "param gcp: Given GCP client """ logging.info("Initializing TeamCommand instance") self.facade = db_facade self.gh = gh self.config = config self.sc = sc + self.gcp = gcp self.desc = "for dealing with teams" self.parser = ArgumentParser(prog="/rocket") self.parser.add_argument("team") @@ -93,6 +97,8 @@ def init_subparsers(self) -> _SubParsersAction: parser_create.add_argument("--lead", type=str, action='store', help="Add given user as team lead" "to created team.") + parser_create.add_argument("--folder", type=str, action='store', + help="Drive folder ID for this team.") """Parser for add command.""" parser_add = subparsers.add_parser("add") @@ -128,6 +134,8 @@ def init_subparsers(self) -> _SubParsersAction: help="Display name the team should have.") parser_edit.add_argument("--platform", type=str, action='store', help="Platform the team should have.") + parser_edit.add_argument("--folder", type=str, action='store', + help="Drive folder ID for this team.") """Parser for lead command.""" parser_lead = subparsers.add_parser("lead") @@ -202,7 +210,8 @@ def handle(self, "name": args.name, "platform": args.platform, "channel": args.channel, - "lead": args.lead + "lead": args.lead, + "folder": args.folder, } return self.create_helper(param_list, user_id) @@ -224,7 +233,8 @@ def handle(self, param_list = { "team_name": args.team_name, "name": args.name, - "platform": args.platform + "platform": args.platform, + "folder": args.folder, } return self.edit_helper(param_list, user_id) @@ -318,6 +328,9 @@ def create_helper(self, param_list, user_id) -> ResponseTuple: if param_list["platform"] is not None: msg += f"platform: {param_list['platform']}, " team.platform = param_list['platform'] + if param_list["folder"] is not None: + msg += f"folder: {param_list['folder']}" + team.folder = param_list['folder'] if param_list["channel"] is not None: msg += "added channel, " for member_id in self.sc.get_channel_users( @@ -467,6 +480,9 @@ def edit_helper(self, param_list, user_id) -> ResponseTuple: if param_list['platform'] is not None: msg += f"platform: {param_list['platform']}" team.platform = param_list['platform'] + if param_list['folder'] is not None: + msg += f"folder: {param_list['folder']}" + team.folder = param_list['folder'] self.facade.store(team) ret = {'attachments': [team.get_attachment()], 'text': msg} return ret, 200 @@ -605,6 +621,9 @@ def refresh_helper(self, user_id) -> ResponseTuple: # add all members (if not already added) to the 'all' team self.refresh_all_team() + + # enforce Drive permissions + self.refresh_drive_permissions() except GithubAPIException as e: logging.error("team refresh unsuccessful due to github error") return "Refresh teams was unsuccessful with " \ @@ -650,3 +669,30 @@ def refresh_all_team(self): self.facade.store(team_all) else: logging.error(f'Could not create {all_name}. Aborting.') + + def refresh_drive_permissions(self): + """ + Refresh Google Drive permissions based on user role. If no GCP client + is provided, this function is a no-op. + """ + + if self.gcp is None: + logging.debug("GCP not enabled, skipping drive permissions") + return + + all_teams: List[Team] = self.facade.query(Team) + for t in all_teams: + if len(t.folder) == 0: + continue + + emails: List[str] = [] + for m in t.members: + if len(m.email) > 0: + emails.append(m.email) + + if len(emails) > 0: + logging.info("Synchronizing permissions for " + + f"{t.github_team_name}'s folder ({t.folder}) " + + f"to {emails}") + self.gcp.set_drive_permissions( + t.folder, t.github_team_name, emails) diff --git a/app/controller/command/parser.py b/app/controller/command/parser.py index 8b2e462c..927a7a4c 100644 --- a/app/controller/command/parser.py +++ b/app/controller/command/parser.py @@ -7,7 +7,8 @@ from db.facade import DBFacade from interface.slack import Bot from interface.github import GithubInterface -from typing import Dict, Any +from interface.gcp import GCPInterface +from typing import Dict, Any, Optional import utils.slack_parse as util import logging from utils.slack_msg_fmt import wrap_slack_code @@ -24,15 +25,19 @@ def __init__(self, db_facade: DBFacade, bot: Bot, gh_interface: GithubInterface, - token_config: TokenCommandConfig): + token_config: TokenCommandConfig, + gcp: Optional[GCPInterface] = None): """Initialize the dictionary of command handlers.""" self.commands: Dict[str, Command] = {} self.__facade = db_facade self.__bot = bot self.__github = gh_interface + self.__gcp = gcp self.commands["user"] = UserCommand(self.__facade, self.__github) self.commands["team"] = TeamCommand(config, self.__facade, - self.__github, self.__bot) + self.__github, + self.__bot, + gcp=self.__gcp) self.commands["token"] = TokenCommand(self.__facade, token_config) self.commands["project"] = ProjectCommand(self.__facade) self.commands["karma"] = KarmaCommand(self.__facade) diff --git a/app/model/team.py b/app/model/team.py index e60c9efe..41488dcc 100644 --- a/app/model/team.py +++ b/app/model/team.py @@ -23,6 +23,7 @@ def __init__(self, self.platform = "" self.team_leads: Set[str] = set() self.members: Set[str] = set() + self.folder = "" def get_attachment(self): """Return slack-formatted attachment (dictionary) for team.""" @@ -31,6 +32,7 @@ def get_attachment(self): ('Github Team Name', self.github_team_name), ('Display Name', self.display_name), ('Platform', self.platform), + ('Folder', self.folder), ('Team Leads', '\n'.join(self.team_leads)), ('Members', '\n'.join(self.members)) ] diff --git a/config/__init__.py b/config/__init__.py index 03b2b87e..37a4cca8 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -32,10 +32,13 @@ class Config: 'AWS_PROJECTS_TABLE': 'aws_projects_tablename', 'AWS_REGION': 'aws_region', 'AWS_LOCAL': 'aws_local', + + 'GCP_SERVICE_ACCOUNT_CREDENTIALS': 'gcp_service_account_credentials', } OPTIONALS = { 'AWS_LOCAL': 'False', - 'GITHUB_DEFAULT_TEAM_NAME': 'all' + 'GITHUB_DEFAULT_TEAM_NAME': 'all', + 'GCP_SERVICE_ACCOUNT_CREDENTIALS': '', } def __init__(self): @@ -91,6 +94,8 @@ def _set_attrs(self): self.aws_region = '' self.aws_local: bool = False + self.gcp_service_account_credentials = '' + class MissingConfigError(Exception): """Exception representing an error while loading credentials.""" diff --git a/docs/Config.md b/docs/Config.md index d29dd1c3..ec928e34 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -71,3 +71,12 @@ The region where the AWS instance is located (leave these as they are). Point all AWS DynamoDB requests to `http://localhost:8000`. Optional, and defaults to `False`. + +## GCP\_SERVICE\_ACCOUNT\_CREDENTIALS + +Service Account credentials for Google Cloud API access. Optional, and defaults +to disabling related features. + +Required permissions when credentials are provided: + +- Drive API - used for synchronizing Drive folder permissions diff --git a/factory/__init__.py b/factory/__init__.py index f74d1248..2d8408e0 100644 --- a/factory/__init__.py +++ b/factory/__init__.py @@ -1,6 +1,8 @@ """All necessary class initializations.""" import random import string +import json +import logging from app.controller.command import CommandParser from app.controller.command.commands.token import TokenCommandConfig @@ -9,10 +11,14 @@ from db.dynamodb import DynamoDB from interface.github import GithubInterface, DefaultGithubFactory from interface.slack import Bot +from interface.gcp import GCPInterface from slack import WebClient from app.controller.webhook.github import GitHubWebhookHandler from app.controller.webhook.slack import SlackEventsHandler from config import Config +from google.oauth2 import service_account as gcp_service_account +from googleapiclient.discovery import build as gcp_build +from typing import Optional def make_dbfacade(config: Config) -> DBFacade: @@ -27,12 +33,16 @@ def make_github_interface(config: Config) -> GithubInterface: def make_command_parser(config: Config, gh: GithubInterface) \ -> CommandParser: + # Initialize database facade = make_dbfacade(config) + # Create Slack bot bot = Bot(WebClient(config.slack_api_token), config.slack_notification_channel) # TODO: make token config expiry configurable token_config = TokenCommandConfig(timedelta(days=7), config.github_key) - return CommandParser(config, facade, bot, gh, token_config) + # Create GCP client (optional) + gcp_client = make_gcp_client(config) + return CommandParser(config, facade, bot, gh, token_config, gcp=gcp_client) def make_github_webhook_handler(gh: GithubInterface, @@ -48,6 +58,25 @@ def make_slack_events_handler(config: Config) -> SlackEventsHandler: return SlackEventsHandler(facade, bot) +def make_gcp_client(config: Config) -> Optional[GCPInterface]: + if len(config.gcp_service_account_credentials) == 0: + logging.info("Google Cloud client not provided, disabling") + return None + + try: + raw_credentials = json.loads(config.gcp_service_account_credentials) + credentials = gcp_service_account.Credentials.\ + from_service_account_info(raw_credentials) + except Exception as e: + logging.error(f"Unable to load GCP credentials, disabling: {e}") + return None + + # Build appropriate service clients. + # See https://github.com/googleapis/google-api-python-client/blob/master/docs/dyn/index.md # noqa + drive = gcp_build('drive', 'v3', credentials=credentials) + return GCPInterface(drive) + + def create_signing_token() -> str: """Create a new, random signing token.""" return ''.join(random.choice(string.ascii_lowercase) for _ in range(24)) diff --git a/interface/gcp.py b/interface/gcp.py new file mode 100644 index 00000000..5329100b --- /dev/null +++ b/interface/gcp.py @@ -0,0 +1,81 @@ +"""Utility classes for interacting with Google APIs""" +from typing import Any, List +from googleapiclient.discovery import Resource +import logging + +default_share_msg = "Rocket has shared a folder with you!" + + +class GCPInterface: + """Utility class for calling Google Cloud Platform (GCP) APIs.""" + + def __init__(self, drive_client: Resource): + logging.info("Initializing Google client interface") + self.drive = drive_client + + def set_drive_permissions(self, scope, drive_id, emails: List[str]): + """ + Creates permissions for the given emails, and removes everyone not + on the list. + + In all cases of API errors, we log and continue, to try and get close + to the desired state of permissions. + """ + + # List existing permissions - we use this to avoid duplicate + # permissions, and to delete ones that should not longer exist. + # See http://googleapis.github.io/google-api-python-client/docs/dyn/drive_v3.permissions.html#list # noqa + existing: List[str] = [] # emails + to_delete: List[str] = [] # permission IDs + try: + # pylint: disable=no-member + list_res = self.drive.permissions().\ + list(drive_id, supportsAllDrives=True) + permissions: List[Any] = list_res['permissions'] + for p in permissions: + email: str = p['emailAddress'] + if email in emails: + existing.append(email) + else: + to_delete.append(p['id']) + except Exception as e: + logging.error("Failed to load permissions for drive item" + + f"({scope}, {drive_id}): {e}") + + # Ensure the folder is shared with everyone as required. + # See http://googleapis.github.io/google-api-python-client/docs/dyn/drive_v3.permissions.html#create # noqa + for email in emails: + if email in existing: + continue + + body = new_create_permission_body(scope, email) + try: + # pylint: disable=no-member + self.drive.permissions().create(drive_id, + body=body, + emailMessage=default_share_msg, + sendNotificationEmail=True, + supportsAllDrives=True) + except Exception as e: + logging.error("Failed to share drive item" + + f"({scope}, {drive_id}) with {email}: {e}") + + # Delete old permissions + # See http://googleapis.github.io/google-api-python-client/docs/dyn/drive_v3.permissions.html#delete # noqa + for p in to_delete: + try: + self.drive.permissions().delete(p, + supportsAllDrives=True) + except Exception as e: + logging.error(f"Failed to delete permission {p} for drive item" + + f" ({scope}, {drive_id}): {e}") + + +def new_create_permission_body(scope, email): + return { + "displayName": f"{scope} (Rocket)", + "emailAddress": email, + "role": "writer", + "sendNotificationEmail": True, + "supportsAllDrives": True, + } diff --git a/mypy.ini b/mypy.ini index 35b6a454..7227389f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -38,3 +38,9 @@ ignore_missing_imports = True [mypy-apscheduler.*] ignore_missing_imports = True + +[mypy-googleapiclient.*] +ignore_missing_imports = True + +[mypy-google.oauth2] +ignore_missing_imports = True diff --git a/tests/app/model/team_test.py b/tests/app/model/team_test.py index ce670a47..7d0621a6 100644 --- a/tests/app/model/team_test.py +++ b/tests/app/model/team_test.py @@ -64,5 +64,6 @@ def test_print(self): " 'display_name': 'Brussel Sprouts'," \ " 'platform': 'web'," \ " 'team_leads': {'U0G9QF9C6'}," \ - " 'members': {'U0G9QF9C6'}}" + " 'members': {'U0G9QF9C6'}," \ + " 'folder': ''}" self.assertEqual(str(self.brussel_sprouts), expected) diff --git a/tests/config/config_test.py b/tests/config/config_test.py index e31d173e..62492c8d 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -28,6 +28,8 @@ def setUp(self): 'AWS_PROJECTS_TABLE': 'projects', 'AWS_REGION': 'us-west-2', 'AWS_LOCAL': 'True', + + 'GCP_SERVICE_ACCOUNT_CREDENTIALS': '{"hello":"world"}', } self.incomplete_config = { 'GITHUB_APP_ID': '2024', @@ -47,7 +49,10 @@ def setUp(self): def test_complete_config(self): """Test a few things from the completed config object.""" os.environ = self.complete_config - self.assertTrue(Config().aws_local) + conf = Config() + self.assertTrue(conf.aws_local) + self.assertEqual(conf.gcp_service_account_credentials, + '{"hello":"world"}') def test_incomplete_config(self): """Test a few things from an incompleted config object.""" diff --git a/tests/interface/gcp_test.py b/tests/interface/gcp_test.py new file mode 100644 index 00000000..77e6b6cc --- /dev/null +++ b/tests/interface/gcp_test.py @@ -0,0 +1,48 @@ +"""Test GCPInterface Class.""" +from interface.gcp import GCPInterface, new_create_permission_body, \ + default_share_msg +from googleapiclient.discovery import Resource +from unittest import mock, TestCase + + +class TestGCPInterface(TestCase): + """Test Case for GCPInterface class.""" + + def setUp(self): + self.mock_drive = mock.MagicMock(Resource) + self.gcp = GCPInterface(self.mock_drive) + + def test_set_drive_permissions(self): + mock_perms = mock.MagicMock() + mock_perms.list = mock.MagicMock(return_value={ + "permissions": [ + { + "id": "1", + "emailAddress": "team@ubclaunchpad.com", + }, + { + "id": "2", + "emailAddress": "strategy@ubclaunchpad.com", + }, + ] + }) + mock_perms.create = mock.MagicMock(return_value={}) + mock_perms.delete = mock.MagicMock(return_value={}) + self.mock_drive.permissions = mock.MagicMock(return_value=mock_perms) + self.gcp.set_drive_permissions('team', 'abcde', [ + 'robert@bobheadxi.dev', + 'team@ubclaunchpad.com', + ]) + + # initial list + mock_perms.list.assert_called() + # one email already exists, share to the new one + mock_perms.create.assert_called_with('abcde', + body=new_create_permission_body( + 'team', + 'robert@bobheadxi.dev'), + emailMessage=default_share_msg, + sendNotificationEmail=True, + supportsAllDrives=True) + # one email should no longer be shared, it is removed + mock_perms.delete.assert_called_with('2', supportsAllDrives=True)