diff --git a/.travis.yml b/.travis.yml index 5119576..95d175d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,7 @@ -sudo: required - language: python python: - - "3.5" - - "3.6" + - "3.8" install: - pip install -e . diff --git a/Dockerfile b/Dockerfile index 6d61b65..d45bd7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,19 @@ FROM ubuntu:xenial # Mostly Mike Grima: mgrima@netflix.com -MAINTAINER NetflixOSS <netflixoss@netflix.com> - -# Install the Python RTM bot itself: -ARG RTM_VERSION -ADD python-rtmbot-${RTM_VERSION}.tar.gz / +LABEL maintainer="netflixoss@netflix.com" RUN \ # Install Python: apt-get update && \ apt-get upgrade -y && \ - apt-get install python3 python3-venv nano -y + apt-get install python3 python3-venv nano curl -y + +# Install the Python RTM bot itself: +ARG RTM_VERSION="0.4.0" +ARG RTM_PATH="python-rtmbot-${RTM_VERSION}" +RUN curl -L https://github.com/slackhq/python-rtmbot/archive/${RTM_VERSION}.tar.gz > /${RTM_PATH}.tar.gz && tar xvzf python-rtmbot-0.4.0.tar.gz + # Add all the other stuff to the plugins: COPY / /python-rtmbot-${RTM_VERSION}/hubcommander @@ -20,19 +22,16 @@ COPY / /python-rtmbot-${RTM_VERSION}/hubcommander RUN \ # Rename the rtmbot: mv /python-rtmbot-${RTM_VERSION} /rtmbot && \ - # Set up the VENV: pyvenv /venv && \ - # Install all the deps: /bin/bash -c "source /venv/bin/activate && pip install --upgrade pip" && \ /bin/bash -c "source /venv/bin/activate && pip install --upgrade setuptools" && \ /bin/bash -c "source /venv/bin/activate && pip install wheel" && \ /bin/bash -c "source /venv/bin/activate && pip install /rtmbot/hubcommander" && \ - # The launcher script: mv /rtmbot/hubcommander/launch_in_docker.sh / && chmod +x /launch_in_docker.sh && \ - rm /rtmbot/hubcommander/python-rtmbot-${RTM_VERSION}.tar.gz + rm /python-rtmbot-${RTM_VERSION}.tar.gz # DEFINE YOUR ENV VARS FOR SECRETS HERE: ENV SLACK_TOKEN="REPLACEMEINCMDLINE" \ diff --git a/README.md b/README.md index 28e8a48..4b3e169 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ administrative or `owner` privileges to your GitHub organization members. | Service | Master | Develop | |:-----------:|:--------:|:---------:| -|Travis CI|[](https://travis-ci.org/Netflix/hubcommander)|[](https://travis-ci.org/Netflix/hubcommander)| +|Travis CI|[](https://travis-ci.com/Netflix/hubcommander)|[](https://travis-ci.com/Netflix/hubcommander)| -How it works? +How does it work? ------------- HubCommander is based on [slackhq/python-rtmbot](https://github.com/slackhq/python-rtmbot) (currently, dependent on release [0.4.0](https://github.com/slackhq/python-rtmbot/releases/tag/0.4.0)) @@ -66,4 +66,4 @@ Please see the documentation [here](docs/installation.md) for details. Contributing --------------- If you are interested in contributing to HubCommander, please review the [contributing documentation](docs/contributing.md). - \ No newline at end of file + diff --git a/auth_plugins/duo/plugin.py b/auth_plugins/duo/plugin.py index 0136749..d16a2ac 100644 --- a/auth_plugins/duo/plugin.py +++ b/auth_plugins/duo/plugin.py @@ -30,35 +30,46 @@ class DuoPlugin(BotAuthPlugin): def __init__(self): super().__init__() - self.client = None + self.clients = {} def setup(self, secrets, **kwargs): - if not secrets.get("DUO_IKEY") or not secrets.get("DUO_SKEY") or not secrets.get("DUO_HOST"): - raise NoSecretsProvidedError("Must provide secrets to enable authentication.") + for variable, secret in secrets.items(): + if "DUO_" in variable: + domain, host, ikey, skey = secret.split(",") + self.clients[domain] = Client(ikey, skey, host) - self.client = Client(secrets["DUO_IKEY"], secrets["DUO_SKEY"], secrets["DUO_HOST"]) + if not len(self.clients): + raise NoSecretsProvidedError("Must provide secrets to enable authentication.") def authenticate(self, data, user_data, **kwargs): + # Which domain does this user belong to? + domain = user_data["profile"]["email"].split("@")[1] + if not self.clients.get(domain): + send_error(data["channel"], "💀 @{}: Duo in this bot is not configured for the domain: `{}`. It needs " + "to be configured for you to run this command." + .format(user_data["name"], domain), markdown=True, thread=data["ts"]) + return False + send_info(data["channel"], "🎟 @{}: Sending a Duo notification to your device. You must approve!" .format(user_data["name"]), markdown=True, ephemeral_user=user_data["id"]) try: - result = self._perform_auth(user_data) + result = self._perform_auth(user_data, self.clients[domain]) except InvalidDuoResponseError as idre: send_error(data["channel"], "💀 @{}: There was a problem communicating with Duo. Got this status: {}. " "Aborting..." - .format(user_data["name"], str(idre)), markdown=True) + .format(user_data["name"], str(idre)), thread=data["ts"], markdown=True) return False except CantDuoUserError as _: send_error(data["channel"], "💀 @{}: I can't Duo authenticate you. Please consult with your identity team." " Aborting..." - .format(user_data["name"]), markdown=True) + .format(user_data["name"]), thread=data["ts"], markdown=True) return False except Exception as e: send_error(data["channel"], "💀 @{}: I encountered some issue with Duo... Here are the details: ```{}```" - .format(user_data["name"], str(e)), markdown=True) + .format(user_data["name"], str(e)), thread=data["ts"], markdown=True) return False if not result: @@ -71,14 +82,14 @@ def authenticate(self, data, user_data, **kwargs): .format(user_data["name"]), markdown=True, ephemeral_user=user_data["id"]) return True - def _perform_auth(self, user_data): + def _perform_auth(self, user_data, client): # Push to devices: duo_params = { "username": user_data["profile"]["email"], "factor": "push", "device": "auto" } - response, data = self.client.api_call("POST", "/auth/v2/auth", duo_params) + response, data = client.api_call("POST", "/auth/v2/auth", duo_params) result = json.loads(data.decode("utf-8")) if response.status != 200: diff --git a/bot_components/decorators.py b/bot_components/decorators.py index 62d6fa0..8e75e71 100644 --- a/bot_components/decorators.py +++ b/bot_components/decorators.py @@ -81,13 +81,10 @@ def perform_additional_verification(plugin_obj, args, **kwargs): elif argument.get("lowercase", True): args[real_arg_name] = args[real_arg_name].lower() - # Perform cleanups? This will remove things like the annoying macOS "smart quotes", - # and the <>, {}, <> from the variables if `cleanup=False` not set. + # Perform cleanups? Removes <>, {}, <> from the variables if `cleanup=False` not set. if argument.get("cleanup", True): args[real_arg_name] = args[real_arg_name].replace("<", "") \ .replace(">", "").replace("{", "").replace("}", "") \ - .replace(u'\u201C', "\"").replace(u'\u201D', "\"") \ - .replace(u'\u2018', "\'").replace(u'\u2019', "\'") \ .replace("[", "").replace("]", "") \ .replace("<", "").replace(">", "") @@ -140,6 +137,10 @@ def decorated_command(plugin_obj, data, user_data): parser.add_argument(argument["name"], **argument["properties"]) + # Remove all the macOS "Smart Quotes": + data["text"] = data["text"].replace(u'\u201C', "\"").replace(u'\u201D', "\"") \ + .replace(u'\u2018', "\'").replace(u'\u2019', "\'") + # Remove the command from the command string: split_args = shlex.split(data["text"])[1:] try: diff --git a/bot_components/parse_functions.py b/bot_components/parse_functions.py index c88c0d9..7038477 100644 --- a/bot_components/parse_functions.py +++ b/bot_components/parse_functions.py @@ -121,6 +121,24 @@ def extract_repo_name(plugin_obj, reponame, **kwargs): return split_repo.replace(">", "") +def extract_multiple_repo_names(plugin_obj, repos, **kwargs): + """ + Does what the above does, but does it for a comma separated list of repos. + :param plugin_obj: + :param repos: + :param kwargs: + :return: + """ + repo_list = repos.split(",") + + parsed_repos = [] + + for repo in repo_list: + parsed_repos.append(extract_repo_name(plugin_obj, repo, **kwargs)) + + return parsed_repos + + def parse_toggles(plugin_obj, toggle, toggle_type="toggle", **kwargs): """ Parses typical toggle values, like off, on, enabled, disabled, true, false, etc. diff --git a/build_docker.sh b/build_docker.sh index bb79671..c8f7126 100755 --- a/build_docker.sh +++ b/build_docker.sh @@ -26,12 +26,12 @@ if [ -z ${BUILD_TAG} ]; then BUILD_TAG="latest" fi -# If this is running in Travis, AND the Python version IS NOT 3.5, then don't build +# If this is running in Travis, AND the Python version IS NOT 3.6, then don't build # the Docker image: if [ $TRAVIS ]; then PYTHON_VERSION=$( python --version ) - if [[ $PYTHON_VERSION != *"3.5"* ]]; then - echo "This only builds Docker images in the Python 3.5 Travis job" + if [[ $PYTHON_VERSION != *"3.6"* ]]; then + echo "This only builds Docker images in the Python 3.6 Travis job" exit 0 fi fi @@ -55,5 +55,12 @@ echo "[-->] Now building the Docker image..." # Build that Docker image... docker build -t netflixoss/hubcommander:${BUILD_TAG} --rm=true . --build-arg RTM_VERSION=${RTM_VERSION} +cmd_st="$?" +if [ $cmd_st -gt 0 ] +then + echo "Error building image. Exiting." + exit $cmd_st +fi + echo echo "DONE!" diff --git a/command_plugins/github/config.py b/command_plugins/github/config.py index 10ac55d..3edae67 100644 --- a/command_plugins/github/config.py +++ b/command_plugins/github/config.py @@ -13,6 +13,11 @@ "perm": "push", # The permission, either "push", "pull", or "admin"... "name": "TeamName" # The name of the team here... } + ], + "collab_validation_teams": [ + # If users are members of any of the teams in this list, they will + # not be added as outside collaborators + "TeamName" ] } } diff --git a/command_plugins/github/decorators.py b/command_plugins/github/decorators.py index 0968b8a..5729a21 100644 --- a/command_plugins/github/decorators.py +++ b/command_plugins/github/decorators.py @@ -9,13 +9,38 @@ from hubcommander.bot_components.slack_comm import send_error -def repo_must_exist(org_arg="org", repo_arg="repo"): +def repo_must_exist(org_arg="org"): def command_decorator(func): def decorated_command(github_plugin, data, user_data, *args, **kwargs): + # Just 1 repo -- or multiple? + if kwargs.get("repo"): + repos = [kwargs["repo"]] + else: + repos = kwargs["repos"] + # Check if the specified GitHub repo exists: - if not github_plugin.check_if_repo_exists(data, user_data, kwargs[repo_arg], kwargs[org_arg]): - return + for repo in repos: + if not github_plugin.check_if_repo_exists(data, user_data, repo, kwargs[org_arg]): + return + + # Run the next function: + return func(github_plugin, data, user_data, *args, **kwargs) + + return decorated_command + + return command_decorator + +def team_must_exist(org_arg="org", team_arg="team"): + def command_decorator(func): + def decorated_command(github_plugin, data, user_data, *args, **kwargs): + # Check if the specified GitHub team exists: + team_id = github_plugin.find_team_id_by_name(kwargs[org_arg], kwargs[team_arg]) + if not team_id: + send_error(data["channel"], "@{}: The GitHub team: {} does not exist.".format(user_data["name"], + kwargs[team_arg]), + thread=data["ts"]) + return # Run the next function: return func(github_plugin, data, user_data, *args, **kwargs) @@ -33,13 +58,15 @@ def decorated_command(github_plugin, data, user_data, *args, **kwargs): if not found_user: send_error(data["channel"], "@{}: The GitHub user: {} does not exist.".format(user_data["name"], - kwargs[user_arg])) + kwargs[user_arg]), + thread=data["ts"]) return except Exception as e: send_error(data["channel"], "@{}: A problem was encountered communicating with GitHub to verify the user's GitHub " - "id. Here are the details:\n{}".format(user_data["name"], str(e))) + "id. Here are the details:\n{}".format(user_data["name"], str(e)), + thread=data["ts"]) return # Run the next function: @@ -66,7 +93,7 @@ def decorated_command(github_plugin, data, user_data, *args, **kwargs): send_error(data["channel"], "@{}: This repository does not have the branch: `{}`.".format(user_data["name"], kwargs[branch_arg]), - markdown=True) + markdown=True, thread=data["ts"]) return # Run the next function: diff --git a/command_plugins/github/plugin.py b/command_plugins/github/plugin.py index d962780..a294fc5 100644 --- a/command_plugins/github/plugin.py +++ b/command_plugins/github/plugin.py @@ -15,10 +15,11 @@ from hubcommander.bot_components.bot_classes import BotCommander from hubcommander.bot_components.decorators import hubcommander_command, auth from hubcommander.bot_components.slack_comm import send_info, send_success, send_error, send_raw -from hubcommander.bot_components.parse_functions import extract_repo_name, parse_toggles +from hubcommander.bot_components.parse_functions import extract_repo_name, parse_toggles, extract_multiple_repo_names from hubcommander.command_plugins.github.config import GITHUB_URL, GITHUB_VERSION, ORGS, USER_COMMAND_DICT from hubcommander.command_plugins.github.parse_functions import lookup_real_org, validate_homepage -from hubcommander.command_plugins.github.decorators import repo_must_exist, github_user_exists, branch_must_exist +from hubcommander.command_plugins.github.decorators import repo_must_exist, github_user_exists, branch_must_exist, \ + team_must_exist class GitHubPlugin(BotCommander): @@ -48,6 +49,21 @@ def __init__(self): "permitted_permissions": ["push", "pull"], # To grant admin, add this to the config for "enabled": True # this command in the config.py. }, + "!RemoveCollab": { + "command": "!RemoveCollab", + "func": self.remove_outside_collab_command, + "user_data_required": True, + "help": "Removes an outside collaborator from a specific repository in a specific GitHub organization.", + "enabled": True # this command in the config.py. + }, + "!SetRepoPermissions": { + "command": "!SetRepoPermissions", + "func": self.set_repo_permissions_command, + "user_data_required": True, + "help": "Sets team permissions to a specific repository in a specific GitHub organization.", + "permitted_permissions": ["push", "pull"], # To grant admin, add this to the config for + "enabled": True # this command in the config.py. + }, "!SetDescription": { "command": "!SetDescription", "func": self.set_description_command, @@ -265,14 +281,15 @@ def set_repo_homepage_command(self, data, user_data, org, repo, homepage): @hubcommander_command( name="!AddCollab", - usage="!AddCollab <OutsideCollabId> <OrgWithRepo> <Repo> <Permission>", + usage="!AddCollab <OutsideCollabId> <OrgWithRepo> <Repos(Comma separated if more than 1)> <Permission>", description="This will add an outside collaborator to a repository with the given permission.", required=[ dict(name="collab", properties=dict(type=str, help="The outside collaborator's GitHub ID.")), dict(name="org", properties=dict(type=str, help="The organization that contains the repo."), validation_func=lookup_real_org, validation_func_kwargs={}), - dict(name="repo", properties=dict(type=str, help="The repository to add the outside collaborator to."), - validation_func=extract_repo_name, validation_func_kwargs={}), + dict(name="repos", properties=dict(type=str, help="A comma separated list (or not if just 1) of repos to " + "add the collaborator to."), + validation_func=extract_multiple_repo_names, validation_func_kwargs={}), dict(name="permission", properties=dict(type=str.lower, help="The permission to grant, must be one " "of: `{values}`"), choices="permitted_permissions"), @@ -282,9 +299,9 @@ def set_repo_homepage_command(self, data, user_data, org, repo, homepage): @auth() @repo_must_exist() @github_user_exists("collab") - def add_outside_collab_command(self, data, user_data, collab, org, repo, permission): + def add_outside_collab_command(self, data, user_data, collab, org, repos, permission): """ - Adds an outside collaborator a repository with a specified permission. + Adds an outside collaborator to repository (or multiple repos) with a specified permission. Command is as follows: !addcollab <outside_collab_id> <organization> <repo> <permission> :param permission: @@ -300,7 +317,8 @@ def add_outside_collab_command(self, data, user_data, collab, org, repo, permiss # Grant access: try: - self.add_outside_collab_to_repo(collab, repo, org, permission) + for r in repos: + self.add_outside_collab_to_repo(collab, r, org, permission) except ValueError as ve: send_error(data["channel"], @@ -317,7 +335,121 @@ def add_outside_collab_command(self, data, user_data, collab, org, repo, permiss # Done: send_success(data["channel"], "@{}: The GitHub user: `{}` has been added as an outside collaborator with `{}` " - "permissions to {}/{}.".format(user_data["name"], collab, permission, + "permissions to {} in {}.".format(user_data["name"], collab, permission, + ", ".join(repos), org), + markdown=True, thread=data["ts"]) + + @hubcommander_command( + name="!RemoveCollab", + usage="!RemoveCollab <OutsideCollabId> <OrgWithRepo> <Repos(Comma separated if more than 1)>", + description="This will remove an outside collaborator from a repository.", + required=[ + dict(name="collab", properties=dict(type=str, help="The outside collaborator's GitHub ID.")), + dict(name="org", properties=dict(type=str, help="The organization that contains the repo."), + validation_func=lookup_real_org, validation_func_kwargs={}), + dict(name="repos", properties=dict(type=str, help="A comma separated list (or not if just 1) of repos to " + "add the collaborator to."), + validation_func=extract_multiple_repo_names, validation_func_kwargs={}), + ], + optional=[] + ) + @auth() + @repo_must_exist() + @github_user_exists("collab") + def remove_outside_collab_command(self, data, user_data, collab, org, repos): + """ + Removes an outside collaborator to repository (or multiple repos). + + Command is as follows: !removecollab <outside_collab_id> <organization> <repo> + :param repo: + :param org: + :param collab: + :param user_data: + :param data: + :return: + """ + # Output that we are doing work: + send_info(data["channel"], "@{}: Working, Please wait...".format(user_data["name"]), thread=data["ts"]) + + # Grant access: + try: + for r in repos: + self.remove_outside_collab_from_repo(collab, r, org) + + except ValueError as ve: + send_error(data["channel"], + "@{}: Problem encountered removing the user as an outside collaborator.\n" + "The response code from GitHub was: {}".format(user_data["name"], str(ve)), thread=data["ts"]) + return + + except Exception as e: + send_error(data["channel"], + "@{}: Problem encountered removing the user as an outside collaborator.\n" + "Here are the details: {}".format(user_data["name"], str(e)), thread=data["ts"]) + return + + # Done: + send_success(data["channel"], + "@{}: The GitHub user: `{}` has been removed as an outside collaborator " + "from {} in {}.".format(user_data["name"], collab, + ", ".join(repos), org), + markdown=True, thread=data["ts"]) + + @hubcommander_command( + name="!SetRepoPermissions", + usage="!SetRepoPermissions <OrgWithRepo> <Repo> <Team> <Permission>", + description="This will set team permissions on a repository .", + required=[ + dict(name="org", properties=dict(type=str, help="The organization that contains the repo."), + validation_func=lookup_real_org, validation_func_kwargs={}), + dict(name="repo", properties=dict(type=str, help="The repository to add the team to."), + validation_func=extract_repo_name, validation_func_kwargs={}), + dict(name="team", properties=dict(type=str, help="The team's name.")), + dict(name="permission", properties=dict(type=str.lower, help="The permission to grant, must be one " + "of: `{values}`"), + choices="permitted_permissions") + ], + optional=[] + ) + @auth() + @repo_must_exist() + @team_must_exist() + def set_repo_permissions_command(self, data, user_data, team, org, repo, permission): + """ + Adds a team to a repository with a specified permission. + + Command is as follows: !SetRepoPermissions <Team> <OrgWithRepo> <Repo> <Permission> + :param permission: + :param repo: + :param org: + :param team: + :param user_data: + :param data: + :return: + """ + # Output that we are doing work: + send_info(data["channel"], "@{}: Working, Please wait...".format(user_data["name"]), thread=data["ts"]) + + # Grant access: + try: + self.set_repo_permissions(repo, org, team, permission) + + except ValueError as ve: + send_error(data["channel"], + "@{}: Problem encountered adding the team.\n" + "The response code from GitHub was: {}".format(user_data["name"], str(ve)), thread=data["ts"]) + return + + except Exception as e: + send_error(data["channel"], + "@{}: Problem encountered adding the team.\n" + "Here are the details: {}".format(user_data["name"], str(e)), thread=data["ts"]) + return + + # Done: + send_success(data["channel"], + "@{}: The GitHub team: `{}` has been added to the repo with `{}` " + "permissions to {}/{}.".format(user_data["name"], team, permission, org, repo), markdown=True, thread=data["ts"]) @@ -338,6 +470,7 @@ def add_outside_collab_command(self, data, user_data, collab, org, repo, permiss ) @auth() @github_user_exists("user_id") + @team_must_exist() def add_user_to_team_command(self, data, user_data, user_id, org, team, role): """ Adds a GitHub user to a team with a specified role. @@ -354,16 +487,9 @@ def add_user_to_team_command(self, data, user_data, user_id, org, team, role): # Output that we are doing work: send_info(data["channel"], "@{}: Working, Please wait...".format(user_data["name"]), thread=data["ts"]) - # Check if team exists, if it does return the id - team_id = self.find_team_id_by_name(org, team) - - if not team_id: - send_error(data["channel"], "The GitHub team does not exist.", thread=data["ts"]) - return - # Do it: try: - self.invite_user_to_gh_org_team(user_id, team_id, role) + self.invite_user_to_gh_org_team(org, team, user_id, role) except ValueError as ve: send_error(data["channel"], @@ -446,7 +572,7 @@ def create_repo_command(self, data, user_data, org, repo): # Grant the proper teams access to the repository: try: for perm_dict in ORGS[org]["new_repo_teams"]: - self.set_repo_permissions(repo, org, perm_dict["id"], perm_dict["perm"]) + self.set_repo_permissions(repo, org, perm_dict["name"], perm_dict["perm"]) except Exception as e: send_error(data["channel"], @@ -920,7 +1046,7 @@ def check_if_repo_exists(self, data, user_data, reponame, real_org): if not result: send_error(data["channel"], - "@{}: This repository does not exist in {}.".format(user_data["name"], real_org), + "@{}: The repository {}/{} does not exist.".format(user_data["name"], real_org, reponame), thread=data["ts"]) return False @@ -1167,6 +1293,17 @@ def set_repo_topics_http(self, org, repo, topics, **kwargs): return True def add_outside_collab_to_repo(self, outside_collab_id, repo_name, real_org, permission): + # Make sure the user is not a member of any of the "validation" teams + # in an org; this prevents us from accidentally adding collaborators who + # are already given the permissions they need via a different mechanism. + if "collab_validation_teams" in ORGS[real_org]: + for team in ORGS[real_org]["collab_validation_teams"]: + if self.check_if_user_is_member_of_team(real_org, outside_collab_id, team): + raise Exception(("User {} is already a member of the {} " + "team in {}. You should not add them as an external " + "collaborator as well. Consider using the !InviteMeTo command " + "instead.").format(outside_collab_id, team, real_org)) + headers = { 'Authorization': 'token {}'.format(self.token), 'Accept': GITHUB_VERSION @@ -1182,6 +1319,22 @@ def add_outside_collab_to_repo(self, outside_collab_id, repo_name, real_org, per if response.status_code not in [201, 204]: raise ValueError(response.status_code) + def remove_outside_collab_from_repo(self, outside_collab_id, repo_name, real_org): + headers = { + 'Authorization': 'token {}'.format(self.token), + 'Accept': GITHUB_VERSION + } + + # Add the outside collab to the repo: + api_part = 'repos/{}/{}/collaborators/{}'.format(real_org, repo_name, outside_collab_id) + response = requests.delete('{}{}'.format(GITHUB_URL, api_part), + headers=headers, + timeout=10) + + # GitHub response code flakiness... + if response.status_code not in [201, 204]: + raise ValueError(response.status_code) + def create_new_repo(self, repo_to_create, org, visibility): headers = { 'Authorization': 'token {}'.format(self.token), @@ -1231,7 +1384,7 @@ def set_repo_permissions(self, repo_to_set, org, team, permission): 'Authorization': 'token {}'.format(self.token), 'Accept': GITHUB_VERSION } - api_part = 'teams/{}/repos/{}/{}'.format(team, org, repo_to_set) + api_part = 'orgs/{}/teams/{}/repos/{}/{}'.format(org, team, org, repo_to_set) data = { "permission": permission @@ -1275,23 +1428,29 @@ def set_branch_protection(self, repo, org, branch, enabled): # See: https://developer.github.com/v3/repos/branches/#enabling-and-disabling-branch-protection headers = { 'Authorization': 'token {}'.format(self.token), - 'Accept': "application/vnd.github.loki-preview+json" } - api_part = 'repos/{}/{}/branches/{}'.format(org, repo, branch) - - data = { - "protection": { - "enabled": enabled + api_part = 'repos/{}/{}/branches/{}/protection'.format(org, repo, branch) + if enabled: + data = { + "required_status_checks": None, + "enforce_admins": None, + "required_pull_request_reviews": None, + "restrictions": None } - } + response = requests.put('{}{}'.format(GITHUB_URL, api_part), json=data, headers=headers, timeout=10) - response = requests.patch('{}{}'.format(GITHUB_URL, api_part), data=json.dumps(data), headers=headers, - timeout=10) + if response.status_code != 200: + message = 'An error was encountered communicating with GitHub: Status Code: {}' \ + .format(response.status_code) + raise requests.exceptions.RequestException(message) - if response.status_code != 200: - message = 'An error was encountered communicating with GitHub: Status Code: {}' \ - .format(response.status_code) - raise requests.exceptions.RequestException(message) + else: + response = requests.delete('{}{}'.format(GITHUB_URL, api_part), headers=headers, timeout=10) + + if response.status_code != 204: + message = 'An error was encountered communicating with GitHub: Status Code: {}' \ + .format(response.status_code) + raise requests.exceptions.RequestException(message) def check_if_user_is_member_of_org(self, github_id, org): # Check if the user exists first: @@ -1317,7 +1476,37 @@ def check_if_user_is_member_of_org(self, github_id, org): return False - def invite_user_to_gh_org_team(self, github_id, team_id, role): + def check_if_user_is_member_of_team(self, org, github_id, team_name): + """ + This will connect to GitHub, and try to retrieve the membership status of a + single user for a team in a given org. + """ + + # Check if the user exists first: + user = self.get_github_user(github_id) + + if not user: + return None + + + headers = { + 'Authorization': 'token {}'.format(self.token), + 'Accept': GITHUB_VERSION + } + + # Retrieve a users membership details. + api_part = 'orgs/{}/teams/{}/memberships/{}'.format(org, team_name, github_id) + response = requests.get('{}{}'.format(GITHUB_URL, api_part), headers=headers, timeout=10) + + if response.status_code == 200: + return True + + elif response.status_code != 404: + raise ValueError("GitHub Problem: Checking membership, status code: {}".format(response.status_code)) + + return False + + def invite_user_to_gh_org_team(self, org, team, username, role): headers = { 'Authorization': 'token {}'.format(self.token), 'Accept': GITHUB_VERSION @@ -1326,7 +1515,7 @@ def invite_user_to_gh_org_team(self, github_id, team_id, role): data = {"role": role} # Add the GitHub user to the team: - api_part = 'teams/{}/memberships/{}'.format(team_id, github_id) + api_part = 'orgs/{}/teams/{}/memberships/{}'.format(org, team, username) response = requests.put('{}{}'.format(GITHUB_URL, api_part), data=json.dumps(data), headers=headers, timeout=10) if response.status_code != 200: diff --git a/command_plugins/travis_ci/plugin.py b/command_plugins/travis_ci/plugin.py index 83e939c..2111ec3 100644 --- a/command_plugins/travis_ci/plugin.py +++ b/command_plugins/travis_ci/plugin.py @@ -119,7 +119,7 @@ def list_org_command(data): @hubcommander_command( name="!EnableTravis", - usage="!EnableTravis <OrgWithRepo> <Repo>", + usage="!EnableTravis <OrgWithRepo> <Repo> [--public=true]", description="This will enable Travis CI on a GitHub repository.", required=[ dict(name="org", properties=dict(type=str, help="The organization that contains the repo."), @@ -127,14 +127,16 @@ def list_org_command(data): dict(name="repo", properties=dict(type=str, help="The repository to enable Travis CI on."), validation_func=extract_repo_name, validation_func_kwargs={}) ], - optional=[] + optional=[dict(name="--public", + properties=dict(type=str, help="When set to true - attempts to enable Travis CI using the public travis-ci.org"))] ) @auth() - def enable_travis_command(self, data, user_data, org, repo): + def enable_travis_command(self, data, user_data, org, repo, public): """ Enables Travis CI on a repository within the organization. - Command is as follows: !enabletravis <organization> <repo> + Command is as follows: !enabletravis <organization> <repo> [--public=true] + :param public: :param repo: :param org: :param user_data: @@ -162,7 +164,7 @@ def enable_travis_command(self, data, user_data, org, repo): "@{}: I encountered a problem:\n\n{}".format(user_data["name"], e), thread=data["ts"]) return - which = "pro" if repo_result["private"] else "public" + which = "public" if (public and public.lower() == 'true') else "pro" try: # Sync with Travis CI so that it knows about the repo: diff --git a/decrypt_creds.py b/decrypt_creds.py index b136742..7df65c7 100644 --- a/decrypt_creds.py +++ b/decrypt_creds.py @@ -10,25 +10,34 @@ def get_credentials(): # For Docker, encryption is assumed to be happening outside of this, and the secrets # are instead being passed in as environment variables: import os - return { + + creds = { # Minimum "SLACK": os.environ["SLACK_TOKEN"], # Optional: "GITHUB": os.environ.get("GITHUB_TOKEN"), - "TRAVIS_PRO_USER": os.environ.get("TRAVIS_PRO_USER"), - "TRAVIS_PRO_ID": os.environ.get("TRAVIS_PRO_ID"), - "TRAVIS_PRO_TOKEN": os.environ.get("TRAVIS_PRO_TOKEN"), - "TRAVIS_PUBLIC_USER": os.environ.get("TRAVIS_PUBLIC_USER"), - "TRAVIS_PUBLIC_ID": os.environ.get("TRAVIS_PUBLIC_ID"), - "TRAVIS_PUBLIC_TOKEN": os.environ.get("TRAVIS_PUBLIC_TOKEN"), - "DUO_HOST": os.environ.get("DUO_HOST"), - "DUO_IKEY": os.environ.get("DUO_IKEY"), - "DUO_SKEY": os.environ.get("DUO_SKEY"), + + # These are named the same as the env var, but these are the env vars should you + # want to leverage the feature: + # "TRAVIS_PRO_USER": os.environ.get("TRAVIS_PRO_USER"), + # "TRAVIS_PRO_ID": os.environ.get("TRAVIS_PRO_ID"), + # "TRAVIS_PRO_TOKEN": os.environ.get("TRAVIS_PRO_TOKEN"), + # "TRAVIS_PUBLIC_USER": os.environ.get("TRAVIS_PUBLIC_USER"), + # "TRAVIS_PUBLIC_ID": os.environ.get("TRAVIS_PUBLIC_ID"), + # "TRAVIS_PUBLIC_TOKEN": os.environ.get("TRAVIS_PUBLIC_TOKEN"), + + # DUO_...NAME_OF_DUO_CRED: "domain-that-is-duod.com,duo_host,duo_ikey,duo_skey" # ADD MORE HERE... } + # Just adds the rest for freely-named ones (Like for Duo): + for variable, value in os.environ.items(): + creds[variable] = value + + return creds + # def kms_decrypt(): # """ @@ -69,9 +78,7 @@ def get_credentials(): "TRAVIS_PUBLIC_USER": "GitHub ID of GitHub account with access to Travis Public", "TRAVIS_PUBLIC_ID": "The ID of the Travis user. Use the Travis API to get this (for Public)", "TRAVIS_PUBLIC_TOKEN": Use the Travis API to get the Travis token (for the Travis Public account)", - "DUO-HOST": "xxxxxxxx.duosecurity.com", - "DUO-IKEY": "The IKEY for Duo", - "DUO-SKEY": "The SKEY for Duo" + "DUO_YOUR_DOMAIN": "your-domain-here.com,xxxxxxxx.duosecurity.com,THEDUOIKEY,THEDUOSKEY" } encrypt_res = kms_client.encrypt(KeyId=kms_arn, Plaintext=bytes(json.dumps(secrets_to_encrypt, indent=4), "utf-8")) diff --git a/docs/authentication.md b/docs/authentication.md index ebd606f..ec03cd9 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,6 +1,6 @@ Authentication Plugins =================== -HubCommander supports the ability to add authentication to the command flow. This is useful to safegurad +HubCommander supports the ability to add authentication to the command flow. This is useful to safeguard specific commands, and additionally, add a speedbump to privileged commands. For organizations making use of Duo, a plugin is supplied that will prompt a user's device for approval @@ -25,10 +25,19 @@ An example for how this is configured can be found in Enabling Duo ------------ Duo is disabled by default. To enable Duo, you will need the following information: + 1. The domain name that is Duo protected + 1. The Duo Host + 1. The "IKEY" + 1. The "SKEY" - - `DUO-HOST`: Your administrator needs to provide you with this - - `DUO-IKEY`: The `IKEY`, provided to you by your administrator - - `DUO-SKEY`: The `SKEY`, provided to you by your administrator +HubCommander supports multiple Duo domains. For this to work, you will need the information above +for the given domain. Additionally, the secrets dictionary needs to be updated such that it has a key that starts +with `DUO_`. This key needs a comma-separated list of the domain, duo host, `ikey`, and `skey`. It needs to look like: + + "DUO_DOMAIN_ONE": "domainone.com,YOURHOST.duosecurity.com,THEIKEY,THESKEY" + "DUO_DOMAIN_TWO": "domaintwo.com,YOUROTHERHOST.duosecurity.com,THEOTHERIKEY,THEOTHERSKEY" + +The email address of the Slack user will determine which domain gets used. With the above information, you need to modify the secrets `dict` that is decrypted by the application on startup. diff --git a/docs/command_config.md b/docs/command_config.md index cf91472..e5e4b4e 100644 --- a/docs/command_config.md +++ b/docs/command_config.md @@ -44,3 +44,12 @@ By default, authentication is disabled. An example of enabling authentication ca [`config.py`](https://github.com/Netflix/hubcommander/blob/master/github/config.py) file. _For more details on authentication plugins, please read the [authentication plugin documentation](authentication.md)._ + + +### Hidden Commands + +If you ever retire a command, you can make it hidden from the `!Help` output. You can modify the command so that it +outputs information redirecting the user to the new and supported command to utilize. + +To do this, in the command configuration, simply remove the `help` text. This command can stil be +executed, but won't appear as a command. diff --git a/docs/installation.md b/docs/installation.md index 8d8ce2c..b34ab7a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -83,6 +83,8 @@ enable the plugins to make authenticated calls to their respective services. You required for plugins to work. 1. Contact your Slack administrator to obtain a Slack token that can be used for bots. + - More specifically, you will need to create a Slack app, and give the app the permissions to be a bot. + - You will need to use the bot token for Oauth in order to get `rtm:stream` permissions. 2. If you haven't already, create a GitHub bot account, and invite it to all the organizations that you manage with the `owner` role. Also, please configure this account with a strong password and 2FA! (Don't forget to back up those recovery codes into a safe place!) @@ -94,7 +96,7 @@ configuration that the plugin supports. For the GitHub plugin, you are required to define a Python `dict` with the organizations that you manage. An example of what this `dict` looks like can be found in the sample -[`github/config.py`](https://github.com/Netflix/hubcommander/blob/master/github/config.py) file. +[`command_plugins/github/config.py`](https://github.com/Netflix/hubcommander/blob/develop/command_plugins/github/config.py) file. At a minimum, you need to specify the real name of the organization, a list of aliases for the orgs (or an empty list), whether the organization can only create public repos (via the `public_only` boolean), as well as diff --git a/docs/travis_ci.md b/docs/travis_ci.md index 85b1c35..6d6e96f 100644 --- a/docs/travis_ci.md +++ b/docs/travis_ci.md @@ -1,14 +1,13 @@ Travis CI Plugin ================= -The [Travis CI](https://travis-ci.org/) plugin features an `!EnableTravis` command which will enable Travis CI +The [Travis CI](https://travis-ci.com/) plugin features an `!EnableTravis` command which will enable Travis CI on a given repository. By default, this plugin is disabled. -This plugin makes an assumption that you have Travis CI Public (for public repos) and -Professional (for private repos) enabled for your GitHub organizations. +This plugin makes an assumption that you have Travis CI enabled for your GitHub organizations. -This plugin will automatically detect which Travis CI (Public vs. Pro) to use based on the public/private -visibility of a given GitHub repository. +Since Travis CI Public (travis-ci.org) [is moving](https://blog.travis-ci.com/2018-05-02-open-source-projects-on-travis-ci-com-with-github-apps) to Travis CI Pro (travis-ci.com), +the plugin will default to using Travis CI Pro. How does it work? ---------------- @@ -26,11 +25,9 @@ then run the API command to enable Travis CI on the repo. Configuration ------------- -This plugin requires access to the Travis CI API version 3 -([currently in closed BETA](https://developer.travis-ci.org/)). You must contact Travis CI's support -and request a GitHub ID to be added into the beta for access to the methods utilized by this plugin. +This plugin requires access to [Travis CI API version 3](https://developer.travis-ci.com/). -Once you are added into the closed beta, you will need to get your Travis CI tokens. These tokens +You will need to get your Travis CI tokens. These tokens are _different_ for public and professional Travis CI. ### GitHub API Token for Travis diff --git a/hubcommander.py b/hubcommander.py index fc98d5f..f2cc67c 100644 --- a/hubcommander.py +++ b/hubcommander.py @@ -108,7 +108,12 @@ def setup(slackclient): if cmd["enabled"]: print("\t[+] Adding command: \'{cmd}\'".format(cmd=cmd["command"])) COMMANDS[cmd["command"].lower()] = cmd - HELP_TEXT.append("`{cmd}` - {help}\n".format(cmd=cmd["command"], help=cmd["help"])) + + # Hidden commands: don't show on the help: + if cmd.get("help"): + HELP_TEXT.append("`{cmd}` - {help}\n".format(cmd=cmd["command"], help=cmd["help"])) + else: + print("\t[!] Not adding help text for hidden command: {}".format(cmd["command"])) else: print("\t[/] Skipping disabled command: \'{cmd}\'".format(cmd=cmd["command"])) print("[+] Successfully enabled command plugin \"{}\"".format(name)) diff --git a/publish_via_travis.sh b/publish_via_travis.sh index b8d34aa..4134dff 100755 --- a/publish_via_travis.sh +++ b/publish_via_travis.sh @@ -20,12 +20,12 @@ # ################################################################################ -# If this is running in Travis, AND the Python version IS NOT 3.5, then don't build +# If this is running in Travis, AND the Python version IS NOT 3.6, then don't build # the Docker image: if [ $TRAVIS ]; then PYTHON_VERSION=$( python --version ) - if [[ $PYTHON_VERSION != *"3.5"* ]]; then - echo "This only publishes Docker images in the Python 3.5 Travis job" + if [[ $PYTHON_VERSION != *"3.6"* ]]; then + echo "This only publishes Docker images in the Python 3.6 Travis job" exit 0 fi else diff --git a/setup.py b/setup.py index 802b76f..944fa03 100644 --- a/setup.py +++ b/setup.py @@ -25,10 +25,10 @@ install_requires = [ 'boto3>=1.4.3', # For KMS support - 'duo_client==3.0', + 'duo_client>=4.0', 'tabulate>=0.7.7', 'validators>=0.11.1', - 'rtmbot==0.4.0' + 'rtmbot>=0.4.0' ] tests_require = [ diff --git a/tests/test_parse_functions.py b/tests/test_parse_functions.py index 94bf346..85e42ad 100644 --- a/tests/test_parse_functions.py +++ b/tests/test_parse_functions.py @@ -53,6 +53,16 @@ def test_extract_repo_name(): assert extract_repo_name(None, uri) == repo +def test_extract_multiple_repo_names(): + from hubcommander.bot_components.parse_functions import extract_multiple_repo_names + + test_repos = ["www.foo.com", "foo", "HubCommander", "netflix.github.io"] + test_repo_strings = "<http://www.foo.com|www.foo.com>,foo,HubCommander," \ + "<https://netflix.github.io|netflix.github.io>" + + assert extract_multiple_repo_names(None, test_repo_strings) == test_repos + + def test_parse_toggles(): from hubcommander.bot_components.parse_functions import parse_toggles, TOGGLE_ON_VALUES, \ TOGGLE_OFF_VALUES, ParseException