diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 488c2efd..73daf3e7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -34,7 +34,7 @@ jobs: uses: docker/setup-buildx-action@v2 - name: Build, tag, and push image to GitHub Container Registry - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v4 with: builder: ${{ steps.buildx.outputs.name }} build-args: GIT-SHA=${{ github.sha }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bd09671e..14c0caea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: - id: check-added-large-files - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.1.0 hooks: - id: black types: @@ -54,6 +54,6 @@ repos: files: .py$ - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.32.2 + rev: v0.33.0 hooks: - id: markdownlint diff --git a/eligibility_server/app.py b/eligibility_server/app.py index d5865ccb..e9d3021e 100644 --- a/eligibility_server/app.py +++ b/eligibility_server/app.py @@ -47,8 +47,7 @@ def healthcheck(): def publickey(): app.logger.info("Request public key") key = get_server_public_key() - pem_data = key.export_to_pem(private_key=False) - return TextResponse(pem_data.decode("utf-8")) + return TextResponse(key.decode("utf-8")) @app.errorhandler(401) diff --git a/eligibility_server/db/setup.py b/eligibility_server/db/setup.py index 0e422f5c..84a1eaac 100644 --- a/eligibility_server/db/setup.py +++ b/eligibility_server/db/setup.py @@ -101,7 +101,7 @@ def import_csv_users(csv_path, remote): quotechar=config.csv_quotechar, ) # unpack each record in data to the 3 columns - for (sub, name, types) in data: + for sub, name, types in data: # type lists are expected to be a comma-separated value and quoted if the CSV delimiter is a comma types = [type.replace(config.csv_quotechar, "") for type in types.split(",") if type] save_user(sub, name, types) diff --git a/eligibility_server/keypair.py b/eligibility_server/keypair.py index 988a9042..5614447f 100644 --- a/eligibility_server/keypair.py +++ b/eligibility_server/keypair.py @@ -1,6 +1,5 @@ import logging -from jwcrypto import jwk import requests from eligibility_server.settings import Configuration @@ -19,10 +18,10 @@ def _read_key_file(key_path): if key_path.startswith("http"): data = requests.get(key_path).text - key = jwk.JWK.from_pem(data.encode("utf8")) + key = data.encode("utf8") else: with open(key_path, "rb") as pemfile: - key = jwk.JWK.from_pem(pemfile.read()) + key = pemfile.read() _CACHE[key_path] = key diff --git a/eligibility_server/verify.py b/eligibility_server/verify.py index 4329d8d3..a3cd92fe 100644 --- a/eligibility_server/verify.py +++ b/eligibility_server/verify.py @@ -2,14 +2,12 @@ Eligibility Verification route """ -import datetime -import json import logging import re +from eligibility_api.server import get_token_payload, make_token, create_response_payload from flask import abort from flask_restful import Resource, reqparse -from jwcrypto import jwe, jws, jwt from eligibility_server import keypair from eligibility_server.db.models import User @@ -60,45 +58,11 @@ def _get_token(self, headers): logger.warning("Invalid token format") raise ValueError("Invalid token format") - def _get_token_payload(self, token): - """Decode a token (JWE(JWS)).""" - try: - # decrypt - decrypted_token = jwe.JWE(algs=[config.jwe_encryption_alg, config.jwe_cek_enc]) - decrypted_token.deserialize(token, key=self.server_private_key) - decrypted_payload = str(decrypted_token.payload, "utf-8") - # verify signature - signed_token = jws.JWS() - signed_token.deserialize(decrypted_payload, key=self.client_public_key, alg=config.jws_signing_alg) - # return final payload - payload = str(signed_token.payload, "utf-8") - return json.loads(payload) - except Exception: - logger.warning("Get token payload failed") - return False - - def _make_token(self, payload): - """Wrap payload in a signed and encrypted JWT for response.""" - # sign the payload with server's private key - header = {"typ": "JWS", "alg": config.jws_signing_alg} - signed_token = jwt.JWT(header=header, claims=payload) - signed_token.make_signed_token(self.server_private_key) - signed_payload = signed_token.serialize() - # encrypt the signed payload with client's public key - header = {"typ": "JWE", "alg": config.jwe_encryption_alg, "enc": config.jwe_cek_enc} - encrypted_token = jwt.JWT(header=header, claims=signed_payload) - encrypted_token.make_encrypted_token(self.client_public_key) - return encrypted_token.serialize() - def _get_response(self, token_payload): try: # craft the response payload using parsed request token sub, name, eligibility = token_payload["sub"], token_payload["name"], list(token_payload["eligibility"]) - resp_payload = dict( - jti=token_payload["jti"], - iss=config.app_name, - iat=int(datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).timestamp()), - ) + resp_payload = create_response_payload(token_payload=token_payload, issuer=config.app_name) # sub format check if re.match(config.sub_format_regex, sub): # eligibility check against db @@ -107,8 +71,16 @@ def _get_response(self, token_payload): else: resp_payload["error"] = {"sub": "invalid"} code = 400 - # make a response token with appropriate response code - return self._make_token(resp_payload), code + # make a response token, and return with appropriate response code + response_token = make_token( + payload=resp_payload, + jws_signing_alg=config.jws_signing_alg, + server_private_key=self.server_private_key, + jwe_encryption_alg=config.jwe_encryption_alg, + jwe_cek_enc=config.jwe_cek_enc, + client_public_key=self.client_public_key, + ) + return response_token, code except (TypeError, ValueError) as ex: logger.error(f"Error: {ex}") abort(400, description="Bad request") @@ -167,7 +139,14 @@ def get(self): # parse inner payload from request token try: token = self._get_token(headers) - token_payload = self._get_token_payload(token) + token_payload = get_token_payload( + token=token, + jwe_encryption_alg=config.jwe_encryption_alg, + jwe_cek_enc=config.jwe_cek_enc, + server_private_key=self.server_private_key, + jws_signing_alg=config.jws_signing_alg, + client_public_key=self.client_public_key, + ) except Exception: logger.error("Bad request") abort(400, description="Bad request") diff --git a/requirements.txt b/requirements.txt index f4e573a7..29db686f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -cryptography==38.0.4 -Flask==2.2.2 +eligibility-api==2023.01.1 +Flask==2.2.3 Flask-RESTful==0.3.9 -Flask-SQLAlchemy==3.0.2 -jwcrypto==1.4.2 -requests==2.28.1 +Flask-SQLAlchemy==3.0.3 +requests==2.28.2 diff --git a/setup.py b/setup.py index 834a2bd7..28152512 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,9 @@ with open("requirements.txt") as f: - install_requires = f.read().strip().split("\n") + requirements = f.read().strip().split("\n") + install_requires = [r for r in requirements if not r.startswith("git+")] + dependency_links = list(set(requirements) - set(install_requires)) with open("README.md") as f: long_description = f.read() @@ -17,7 +19,7 @@ setup( name="eligibility-server", - version="2022.12.1", + version="2023.02.1", description="Server implementation of the Eligibility Verification API", long_description=long_description, long_description_content_type="text/markdown", @@ -30,5 +32,6 @@ packages=find_packages(), include_package_data=True, install_requires=install_requires, + dependency_links=dependency_links, license="AGPL-3.0", ) diff --git a/terraform/pipeline/workspace.py b/terraform/pipeline/workspace.py index 4915d03f..526296e8 100644 --- a/terraform/pipeline/workspace.py +++ b/terraform/pipeline/workspace.py @@ -12,7 +12,7 @@ if REASON == "PullRequest" and TARGET in ENV_BRANCHES: # it's a pull request against one of the environment branches, so use the target branch environment = TARGET -elif REASON == "IndividualCI" and SOURCE in ENV_BRANCHES: +elif REASON in ["IndividualCI", "Manual"] and SOURCE in ENV_BRANCHES: # it's being run on one of the environment branches, so use that environment = SOURCE else: diff --git a/terraform/suppress.arm.json b/terraform/suppress.arm.json new file mode 100644 index 00000000..d208061d --- /dev/null +++ b/terraform/suppress.arm.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "metricAlertID": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.AlertsManagement/actionRules", + "apiVersion": "2021-08-08", + "name": "suppress-nightly-downtime", + "location": "Global", + "properties": { + "description": "Suppresses alerts during the scheduled nightly downtime", + "scopes": ["[parameters('metricAlertID')]"], + "actions": [ + { + "actionType": "RemoveAllActionGroups" + } + ], + "schedule": { + "timeZone": "Eastern Standard Time", + "recurrences": [ + { + "recurrenceType": "Daily", + "startTime": "06:00:00", + "endTime": "06:10:00" + } + ] + }, + "enabled": true + } + } + ] +} diff --git a/terraform/uptime.tf b/terraform/uptime.tf index b613e9c3..7a0d2176 100644 --- a/terraform/uptime.tf +++ b/terraform/uptime.tf @@ -8,3 +8,20 @@ module "healthcheck" { name = "mst-courtesy-cards-eligibility-server-${local.env_name}-healthcheck" url = "https://${azurerm_linux_web_app.main.default_hostname}/healthcheck" } + +# ignore when app restarts as data is being reloaded +# https://learn.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-processing-rules +# the Terraform resource doesn't support time windows, so need to drop down to an ARM template instead +# https://github.com/hashicorp/terraform-provider-azurerm/issues/16726 +resource "azurerm_resource_group_template_deployment" "suppress_nightly_downtime" { + name = "suppress-nightly-downtime" + resource_group_name = data.azurerm_resource_group.main.name + deployment_mode = "Incremental" + parameters_content = jsonencode({ + "metricAlertID" = { + value = module.healthcheck.metric_alert_id + } + }) + # https://learn.microsoft.com/en-us/azure/templates/microsoft.alertsmanagement/actionrules?tabs=json&pivots=deployment-language-arm-template + template_content = file("${path.module}/suppress.arm.json") +} diff --git a/terraform/uptime/outputs.tf b/terraform/uptime/outputs.tf new file mode 100644 index 00000000..ec1136f5 --- /dev/null +++ b/terraform/uptime/outputs.tf @@ -0,0 +1,3 @@ +output "metric_alert_id" { + value = azurerm_monitor_metric_alert.uptime.id +} diff --git a/tests/test_app.py b/tests/test_app.py index 3a81b874..341e9700 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -30,4 +30,4 @@ def test_publickey(client): response = client.get("publickey") assert response.status_code == 200 assert response.mimetype == "text/plain" - assert response.text == get_server_public_key().export_to_pem().decode("utf-8") + assert response.text == get_server_public_key().decode("utf-8") diff --git a/tests/test_keypair.py b/tests/test_keypair.py index 697d8600..f6664eaf 100644 --- a/tests/test_keypair.py +++ b/tests/test_keypair.py @@ -39,8 +39,8 @@ def test_read_key_file_local(mocker, sample_key_path_local, spy_open): # check that there was a call to open the default path assert mocker.call(sample_key_path_local, "rb") in spy_open.call_args_list assert key - assert key.key_id - assert key.key_type == "RSA" + with open(sample_key_path_local, "rb") as file: + assert key == file.read() @pytest.mark.usefixtures("reset_cache") @@ -52,8 +52,7 @@ def test_read_key_file_remote(sample_key_path_remote, spy_open, spy_requests_get # check that we made a get request spy_requests_get.assert_called_with(sample_key_path_remote) assert key - assert key.key_id - assert key.key_type == "RSA" + assert key == requests.get(sample_key_path_remote).text.encode("utf8") @pytest.mark.usefixtures("reset_cache") diff --git a/tests/test_verify.py b/tests/test_verify.py index 804e3e12..34e9fba6 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -18,7 +18,7 @@ def test_Verify_client_get_bad_request(mocker, client): mocked_headers = {"TOKEN_HEADER": "blah"} mocker.patch("eligibility_server.verify.Verify._check_headers", return_value=mocked_headers) mocker.patch("eligibility_server.verify.Verify._get_token", return_value="token") - mocker.patch("eligibility_server.verify.Verify._get_token_payload", return_value="bad token") + mocker.patch("eligibility_server.verify.get_token_payload", return_value="bad token") response = client.get("/verify", json=token_payload) assert response.status_code == 400