Skip to content

Commit

Permalink
Merge pull request #6 from cisco/health-check
Browse files Browse the repository at this point in the history
Health check
  • Loading branch information
jjjacksn authored Feb 10, 2021
2 parents 2471c43 + 251b703 commit 5aad8b7
Show file tree
Hide file tree
Showing 13 changed files with 283 additions and 96 deletions.
11 changes: 8 additions & 3 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,24 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7]

python-version:
- 3.6
- 3.7
mindmeld-version:
- 4.3.1
- 4.3.4rc3
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
- name: Install dependencies (MindMeld ${{ matrix.mindmeld-version }})
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install
poetry run pip install mindmeld==${{ matrix.mindmeld-version }}
- name: Lintme
run: |
./lintme
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Added a check to skill server implementation
- Added `wxa_sdk check` command-line command to invoke a skill's health check
- Added this changelog

### Changed
- Changed response serialization in server to be compatible with mindmeld 4.3.4

## 0.1.1 - 2020-02-19

- Initial implementation
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,16 @@ Installing the webex_assistant_sdk package adds a wxa_sdk command line applicati

```bash
$ wxa_sdk -h
usage: wxa_sdk [-h] {new,generate-keys,invoke} ...
usage: wxa_sdk [-h] {new,generate-keys,invoke,check} ...

positional arguments:
{new,generate-keys,invoke}
{new,generate-keys,invoke,check}
new create a new skill project
generate-keys generate keys for use with a Webex Assistant Skill
invoke invoke a skill simulating a request from Webex
Assistant
check check the health and configuration of a Webex
Assistant Skill

optional arguments:
-h, --help show this help message and exit
Expand Down
106 changes: 53 additions & 53 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "webex-assistant-sdk"
version = "0.1.1"
version = "0.1.2-dev"
description = "An SDK for developing applications for Webex Assistant."
readme = "README.md"
authors = ["Minh Vo Thanh <[email protected]>", "J.J. Jackson <[email protected]>"]
Expand All @@ -20,12 +20,12 @@ python = "^3.6"
# cryptography maintains backward compatibility for 3 minor versions
# and each 10th minor release is major (2.8 -> 2.9 -> 3.0 ...)
cryptography = ">=2.8,<3.4"
mindmeld = "^4.3.0"
mindmeld = "^4.3.1"
requests = "^2.22.0"
spacy = "^2.3.0"

[tool.poetry.dev-dependencies]
black = '^20.8b1'
black = '^20.8b0'
flake8 = '^3.7.8'
isort = {version = '^4.3', extras = ['toml']}
pre-commit = '^1.18'
Expand Down
21 changes: 10 additions & 11 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
from .skill import app


@pytest.fixture
def skill_dir():
@pytest.fixture(name='skill_dir')
def _skill_dir():
return os.path.join(os.path.realpath(os.path.dirname(__file__)), 'skill')


@pytest.fixture
def keys_dir(skill_dir): # pylint: disable=redefined-outer-name
@pytest.fixture(name='keys_dir')
def _keys_dir(skill_dir):
return skill_dir


Expand All @@ -28,23 +28,22 @@ def responder():
return SkillResponder()


@pytest.fixture
def skill_nlp(skill_dir) -> NaturalLanguageProcessor: # pylint: disable=redefined-outer-name
@pytest.fixture(name='skill_nlp')
def _skill_nlp(skill_dir) -> NaturalLanguageProcessor:
"""Provides a built processor instance"""
nlp = NaturalLanguageProcessor(app_path=skill_dir)
nlp.build()
nlp.dump()
return nlp


# pylint: disable=redefined-outer-name
@pytest.fixture
def skill_app(skill_nlp) -> SkillApplication:
@pytest.fixture(name='skill_app')
def _skill_app(skill_nlp) -> SkillApplication:
app.lazy_init(nlp=skill_nlp)
return app


@pytest.fixture
def client(skill_app: SkillApplication): # pylint: disable=redefined-outer-name
@pytest.fixture(name='client')
def _client(skill_app: SkillApplication):
server = skill_app._server.test_client()
yield server
47 changes: 44 additions & 3 deletions tests/test_skill_server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
from urllib.request import quote

from webex_assistant_sdk import SkillApplication
from webex_assistant_sdk.crypto import (
Expand Down Expand Up @@ -52,7 +53,8 @@ def test_parse_endpoint_success(client, skill_dir):
response_data = json.loads(response.data.decode('utf8'))
assert response_data['dialogue_state'] == 'welcome'
assert response_data['challenge'] == 'a challenge'
assert set(response_data.keys()) == {
# Use >= set comparison as newer versions of mindmeld may add fields
assert set(response_data.keys()) >= {
'history',
'params',
'frame',
Expand All @@ -67,6 +69,45 @@ def test_parse_endpoint_success(client, skill_dir):


def test_health_endpoint(client):
response = client.get('/health')
response = client.get('/parse')
assert response.status_code == 200
assert set(json.loads(response.data.decode('utf8')).keys()) == {'sdk_version', 'status'}
assert set(json.loads(response.data.decode('utf8')).keys()) == {'api_version', 'status'}


def test_health_endpoint_check(client, skill_dir):
key = load_public_key(get_file_contents(os.path.join(skill_dir, 'id_rsa.pub')))
secret = 'some secret'
challenge = 'challenge'
encrypted_challenge = encrypt(message=challenge, public_key=key)
signature = generate_signature(secret, challenge)
response = client.get(
f'/parse?challenge={quote(encrypted_challenge)}',
headers={'X-Webex-Assistant-Signature': signature},
)

assert response.status_code == 200
response_data = json.loads(response.data.decode('utf8'))

assert response_data == {
'status': 'up',
'api_version': '1.0',
'validated': True,
'challenge': challenge,
}


def test_health_endpoint_check_failed(client, skill_dir):
key = load_public_key(get_file_contents(os.path.join(skill_dir, 'id_rsa.pub')))
secret = 'wrong secret'
challenge = 'challenge'
encrypted_challenge = encrypt(message=challenge, public_key=key)
signature = generate_signature(secret, challenge)
response = client.get(
f'/parse?challenge={quote(encrypted_challenge)}',
headers={'X-Webex-Assistant-Signature': signature},
)

assert response.status_code == 400
response_data = json.loads(response.data.decode('utf8'))

assert response_data == {'status': 'error', 'error': 'Invalid signature', 'api_version': '1.0'}
42 changes: 40 additions & 2 deletions webex_assistant_sdk/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ def get_parser():
invoke_parser.add_argument(
'-f', '--frame', default=None, help='the JSON frame to use in the invocation'
)

check_parser = subparsers.add_parser(
'check', help='check the health and configuration of a Webex Assistant Skill'
)
check_parser.add_argument(
'-s',
'--secret',
type=str,
action=PasswordPromptAction,
help="the skill's secret",
prompt='Enter skill secret: ',
)
check_parser.add_argument(
'-k', '--key-file', help="the path to the skill's public key file on disk", required=True
)
check_parser.add_argument(
'-U',
'--url',
default='http://localhost:7150/parse',
help='the URL where the skill is served',
)
return parser


Expand All @@ -112,7 +133,7 @@ def generate_keys(filename, key_type, password=None):
print('done')


def invoke_agent(secret, key_file, url, context=None, frame=None):
def invoke_skill(secret, key_file, url, context=None, frame=None):
from . import crypto, helpers # pylint: disable=import-outside-toplevel

public_key = crypto.load_public_key_from_file(key_file)
Expand Down Expand Up @@ -141,6 +162,15 @@ def invoke_agent(secret, key_file, url, context=None, frame=None):
pprint.pprint(directives, indent=2, width=width)


def check_skill(secret, key_file, url):
from . import crypto, helpers # pylint: disable=import-outside-toplevel

public_key = crypto.load_public_key_from_file(key_file)
res = helpers.make_health_check(secret, public_key, url)

print(res)


def parse_json_argument(name, arg):
try:
return json.loads(arg)
Expand Down Expand Up @@ -169,7 +199,15 @@ def main():

context = parse_json_argument('context', args.context) if args.context else None
frame = parse_json_argument('frame', args.frame) if args.frame else None
invoke_agent(args.secret, args.key_file, url=args.url, context=context, frame=frame)
invoke_skill(args.secret, args.key_file, url=args.url, context=context, frame=frame)
return

if args.command == 'check':
if not args.secret:
# reparse with added '-s'
# Note: for some reason we have to pop off the first arg when reparsing
args = parser.parse_args(args=sys.argv[1:] + ['-s'])
check_skill(args.secret, args.key_file, url=args.url)
return

parser.print_help()
Expand Down
4 changes: 3 additions & 1 deletion webex_assistant_sdk/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
__version__ = '0.1.1'
version = (0, 1, 2, 'dev')
api_version = (1, 0)
__version__ = '.'.join((str(i) for i in version))
5 changes: 4 additions & 1 deletion webex_assistant_sdk/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ def decrypt(private_key, cipher_string: str) -> str:
encrypted_temp_key: str = encrypted_components[0]
# only the first '.' character is special -- we should treat the remainder as the content
encrypted_message: str = '.'.join(encrypted_components[1:])
decoded_temp_key = base64.b64decode(encrypted_temp_key.encode('utf-8'))
try:
decoded_temp_key = base64.b64decode(encrypted_temp_key.encode('utf-8'))
except binascii.Error as exc:
raise EncryptionKeyError('Message cannot be decoded') from exc
pad = padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None
)
Expand Down
2 changes: 2 additions & 0 deletions webex_assistant_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class RequestValidationError(WebexAssistantSDKException):


class SignatureValidationError(RequestValidationError):
"""An exception raised when signature validation fails"""

pass


Expand Down
57 changes: 52 additions & 5 deletions webex_assistant_sdk/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ def validate_request(
Tuple[Mapping, str]: The decrypted request body and a challenge string
Raises:
RequestValidationError: Description
ServerChallengeValidationError: Description
SignatureValidationError: Description
RequestValidationError: raised when request data cannot be decrypted or decoded
ServerChallengeValidationError: raised when request is missing challenge
SignatureValidationError: raised when signature cannot be validated
"""
try:
signature = headers.get('X-Webex-Assistant-Signature')
Expand All @@ -59,7 +59,7 @@ def validate_request(

challenge = request_json.get('challenge')
if not challenge:
raise ServerChallengeValidationError('Bad request')
raise ServerChallengeValidationError('Missing challenge')

except RequestValidationError:
raise
Expand All @@ -70,6 +70,33 @@ def validate_request(
return request_json, challenge


def validate_health_check(
secret: str, private_key, headers: Mapping, encrypted_challenge: str
) -> str:
try:
signature = headers.get('X-Webex-Assistant-Signature')
if encrypted_challenge and not signature:
raise SignatureValidationError('Missing signature')
if signature and not encrypted_challenge:
raise ServerChallengeValidationError('Missing challenge')

try:
challenge = crypto.decrypt(private_key, encrypted_challenge)
except EncryptionKeyError as exc:
raise RequestValidationError('Invalid payload encryption') from exc

if not crypto.verify_signature(secret, challenge, signature):
raise SignatureValidationError('Invalid signature')

except RequestValidationError:
raise
except Exception as exc:
logger.exception('Unexpected error validating health check')
raise RequestValidationError('Cannot validate health check') from exc

return challenge


def make_request(
secret,
public_key,
Expand Down Expand Up @@ -120,4 +147,24 @@ def make_request(
if response_body.get('challenge') != challenge:
raise ClientChallengeValidationError('Response failed challenge')

return res.json()
return response_body


def make_health_check(secret, public_key, url='http://0.0.0.0:7150/parse'):
challenge = os.urandom(64).hex()
encrypted_challenge = crypto.encrypt(public_key, challenge)
headers = {
'X-Webex-Assistant-Signature': crypto.generate_signature(secret, challenge),
'Accept': 'application/json',
}
res = requests.get(url, headers=headers, params={'payload': encrypted_challenge})

if res.status_code != 200:
raise ResponseValidationError('Health check failed')

response_body = res.json()

if response_body.get('challenge') != challenge:
raise ClientChallengeValidationError('Response failed challenge')

return response_body
Loading

0 comments on commit 5aad8b7

Please sign in to comment.