Skip to content

Commit

Permalink
Deploy 2023.02.1 to prod (#246)
Browse files Browse the repository at this point in the history
  • Loading branch information
afeld authored Feb 28, 2023
2 parents 6e9ecba + 382be1f commit 63f61d6
Show file tree
Hide file tree
Showing 15 changed files with 99 additions and 64 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
3 changes: 1 addition & 2 deletions eligibility_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion eligibility_server/db/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions eligibility_server/keypair.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import logging

from jwcrypto import jwk
import requests

from eligibility_server.settings import Configuration
Expand All @@ -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

Expand Down
61 changes: 20 additions & 41 deletions eligibility_server/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 4 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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",
Expand All @@ -30,5 +32,6 @@
packages=find_packages(),
include_package_data=True,
install_requires=install_requires,
dependency_links=dependency_links,
license="AGPL-3.0",
)
2 changes: 1 addition & 1 deletion terraform/pipeline/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions terraform/suppress.arm.json
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
17 changes: 17 additions & 0 deletions terraform/uptime.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
3 changes: 3 additions & 0 deletions terraform/uptime/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "metric_alert_id" {
value = azurerm_monitor_metric_alert.uptime.id
}
2 changes: 1 addition & 1 deletion tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
7 changes: 3 additions & 4 deletions tests/test_keypair.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 63f61d6

Please sign in to comment.