Skip to content

Commit

Permalink
Merge pull request #7 from lukany/feature/dynamic-secret
Browse files Browse the repository at this point in the history
Feature/dynamic secret
  • Loading branch information
lukany authored Jun 14, 2021
2 parents 4ca13f5 + f4da586 commit 7ec8ea0
Show file tree
Hide file tree
Showing 12 changed files with 309 additions and 195 deletions.
6 changes: 0 additions & 6 deletions .pylintrc

This file was deleted.

7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ by `GGCI_CONFIG` environment variable.
Example config:

```YAML
gitlab_token: xxxxxxx

google_chat_url: https://chat.googleapis.com/v1/spaces/...
ggci_secret: xxxxxxx

user_mappings: # OPTIONAL, used for mentions; key: GitLab ID, val: Google Chat ID
5894317: 120984893489384029908 # Gandalf
Expand All @@ -57,8 +55,7 @@ Alternatively, `create_app()` also accepts optional argument `config` of type
from ggci import Config, create_app
config = Config(
gitlab_token='xxxxxxxxxx',
google_chat_url='https://chat.googleapis.com/v1/spaces/...',
ggci_secret='xxxxxxx',
user_mappings={
5894317: 120984893489384029908, # Gandalf
4985120: 109238409842809234892, # Chuck Norris
Expand Down
4 changes: 1 addition & 3 deletions example_config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
gitlab_token: xxxxxxx

google_chat_url: https://chat.googleapis.com/v1/spaces/...
ggci_secret: xxxxxxx

user_mappings: # OPTIONAL, used for mentions; key: GitLab ID, val: Google Chat ID
5894317: 120984893489384029908 # Gandalf
Expand Down
15 changes: 4 additions & 11 deletions ggci/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ class ConfigError(Exception):
class Config:
def __init__(
self,
gitlab_token: str,
google_chat_url: str,
ggci_secret: str,
user_mappings: Optional[Dict[int, int]] = None,
**kwargs,
):
Expand All @@ -27,14 +26,9 @@ def __init__(
if user_mappings is None:
user_mappings = {}

if not isinstance(gitlab_token, str):
if not isinstance(ggci_secret, str):
raise TypeError(
f'gitlab_token must be of type str, got: {type(gitlab_token)}'
)
if not isinstance(google_chat_url, str):
raise TypeError(
f'google_chat_url must be of type str, got:'
f' {type(google_chat_url)}'
f'ggci-secret must be of type str, got: {type(ggci_secret)}'
)
if not isinstance(user_mappings, dict):
raise TypeError(
Expand All @@ -43,8 +37,7 @@ def __init__(
)

self._config_dict = {
'GGCI_GITLAB_TOKEN': gitlab_token,
'GGCI_GOOGLE_CHAT_URL': google_chat_url,
'GGCI_SECRET': ggci_secret,
'GGCI_USER_MAPPINGS': user_mappings,
**{key.upper(): val for key, val in kwargs.items()},
}
Expand Down
63 changes: 56 additions & 7 deletions ggci/forwarder.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,75 @@
import re
import secrets
from typing import Tuple

from flask import Blueprint, current_app, request

from ggci.google_chat import send_message
from ggci.gitlab import MergeRequestEvent, UnsupportedEvent
from ggci.google_chat import GoogleChatError, send_message
from ggci.gitlab import MergeRequestEvent, InvalidFormat, UnsupportedEvent

_GITLAB_TOKEN_REGEX_PATTERN = 'GGCI-SECRET=(.*);GOOGLE-CHAT-URL=(.*)'
_GOOGLE_CHAT_REGEX_PATTERN = 'https://chat.googleapis.com/v1/spaces/.*'
_UNAUTHORIZED_RESPONSE = ('Unauthorized', 401)

bp = Blueprint('forwarder', __name__)


def _get_gitlab_token():
return current_app.config['GGCI_GITLAB_TOKEN']
class IncorrectTokenFormat(Exception):
pass


class Unauthorized(Exception):
pass


def _parse_gitlab_token(gitlab_token: str) -> Tuple[str, str]:
if not isinstance(gitlab_token, str):
raise IncorrectTokenFormat()
match = re.match(_GITLAB_TOKEN_REGEX_PATTERN, gitlab_token)
if match is None:
raise IncorrectTokenFormat()
ggci_secret, google_chat_url = match.groups()
return ggci_secret, google_chat_url


def _authorize(ggci_secret: str) -> None:
if not secrets.compare_digest(
ggci_secret, current_app.config['GGCI_SECRET']
):
raise Unauthorized()


@bp.route('/', methods=['POST'])
def forward():

if request.headers.get('X-Gitlab-Token') != _get_gitlab_token():
return '', 401
try:
ggci_secret, google_chat_url = _parse_gitlab_token(
gitlab_token=request.headers.get('X-Gitlab-Token'),
)
except IncorrectTokenFormat:
return _UNAUTHORIZED_RESPONSE

try:
_authorize(ggci_secret=ggci_secret)
except Unauthorized:
return _UNAUTHORIZED_RESPONSE

if re.fullmatch(_GOOGLE_CHAT_REGEX_PATTERN, google_chat_url) is None:
return (
f'Google Chat URL does not match the following regex pattern:'
f' {_GOOGLE_CHAT_REGEX_PATTERN}'
), 400

try:
mr_event = MergeRequestEvent.from_dict(request.json)
except InvalidFormat as exc:
return str(exc), 400
except UnsupportedEvent as exc:
return str(exc), 501

send_message(message=mr_event.create_message())
try:
send_message(url=google_chat_url, message=mr_event.create_message())
except GoogleChatError as exc:
return str(exc), 500

return 'Success', 200
13 changes: 11 additions & 2 deletions ggci/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class UnsupportedEvent(Exception):
pass


class InvalidFormat(Exception):
pass


class Action(Enum):
OPEN = 'open'
UPDATE = 'update'
Expand Down Expand Up @@ -55,10 +59,15 @@ class MergeRequestEvent:
@classmethod
def from_dict(cls, event_dict: Dict[str, Any]) -> MergeRequestEvent:

if event_dict['event_type'] != 'merge_request':
try:
event_type = event_dict['event_type']
except KeyError as exc:
raise InvalidFormat('Missing event_type') from exc

if event_type != 'merge_request':
raise UnsupportedEvent(
f'Only "merge_request" events are currently supported, got:'
f' {event_dict["event_type"]}'
f' {event_type}'
)

mr_attrs = event_dict['object_attributes']
Expand Down
29 changes: 17 additions & 12 deletions ggci/google_chat.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,37 @@
import logging
from typing import Optional

import requests
from flask import current_app

import tenacity

from ggci.commons import Message

_LOGGER = logging.getLogger(__name__)


def _get_google_chat_url() -> Optional[str]:
return current_app.config.get('GGCI_GOOGLE_CHAT_URL')
class GoogleChatError(Exception):
def __init__(self, error):

if isinstance(error, tenacity.Future):
# Tenacity initiates `retry_error_cls` with tenacity.Future.
# containing more information along with the exception itself
error = error.exception()

def send_message(message: Message) -> None:
super().__init__(error)

url = _get_google_chat_url()

if not isinstance(url, str):
raise TypeError(f'Google Chat URL must be str, got: {type(url)}')
if not url:
raise ValueError('Google Chat URL must not be empty')
@tenacity.retry(
retry=tenacity.retry_if_exception_type(requests.exceptions.HTTPError),
stop=tenacity.stop_after_attempt(5),
wait=tenacity.wait.wait_random(min=0.05, max=0.2),
retry_error_cls=GoogleChatError,
)
def send_message(url: str, message: Message) -> None:

_LOGGER.info('Sending message...')
_LOGGER.debug('Message: %s', message)

if message.thread_key is not None:
url += f'&threadKey=GGCI_{message.thread_key}'

requests.post(url=url, json={'text': message.text})
response = requests.post(url=url, json={'text': message.text})
response.raise_for_status()
Loading

0 comments on commit 7ec8ea0

Please sign in to comment.