diff --git a/Makefile b/Makefile
index 631c271b..fb2c7b86 100644
--- a/Makefile
+++ b/Makefile
@@ -18,7 +18,6 @@ help:
@echo "make clean Delete development artefacts (cached files, "
@echo " dependencies, etc)"
@echo "make requirements Compile all requirements files"
- @echo "make allow-list Create an SQL file for adding sites to the allow list"
.PHONY: dev
dev: python
@@ -109,8 +108,4 @@ web: python
python:
@./bin/install-python
-.PHONY: allow-list
-allow-list:
- @tox -qe dev --run-command 'python bin/add_to_allow_list.py'
-
DOCKER_TAG = dev
diff --git a/bin/add_to_allow_list.py b/bin/add_to_allow_list.py
index 430b653e..6d64445e 100755
--- a/bin/add_to_allow_list.py
+++ b/bin/add_to_allow_list.py
@@ -8,23 +8,18 @@
* Read that file (as a CSV)
* Spot rows which don't have a result yet
- * Check if we can allow them
- * Create an SQL file to add to the running server
+ * Check if we can allow them and add them to the DB if so
* Create an updated CSV file with the results of the run
"""
import csv
-import json
import os
from argparse import ArgumentParser
from datetime import date
-from pkg_resources import resource_filename
-from pyramid.paster import bootstrap
+import requests
from checkmate.models import Detection, Reason, Source
-from checkmate.services import URLCheckerService
-from checkmate.url import hash_for_rule
parser = ArgumentParser("A script for adding to the allow list")
parser.add_argument(
@@ -36,7 +31,8 @@
parser.add_argument(
"-o", "--output_csv", default="allow_list.done.csv", help="Output CSV file"
)
-parser.add_argument("-s", "--sql", default="allow_list.sql", help="Output SQL file")
+parser.add_argument("-s", "--session", required=True, help="Admin session cookie value")
+parser.add_argument("-r", "--route", required=True, help="Add rule end-point")
class AllowListCSV:
@@ -94,57 +90,39 @@ def write(cls, handle, rows):
ALLOW_LIST_DETECTION = Detection(Reason.NOT_ALLOWED, Source.ALLOW_LIST)
-def check_rows(rows, checker):
- """Check each row for detections and hash if none are found.
+class Checkmate:
+ def __init__(self, route, session):
+ self.route = route
+ self.session = session
- This will skip existing rows with results from previous runs.
- """
+ def allow_url(self, url):
+ response = requests.post(
+ self.route,
+ headers={"Cookie": f"session={self.session}"},
+ json={"data": {"type": "AllowRule", "meta": {"url": url}}},
+ )
- for row in rows:
- # This has already been dealt with
- if row.result:
- continue
+ if response.ok:
+ attributes = response.json()["data"]["attributes"]
+ hex_hash = attributes["hash"]
+ rule = attributes["rule"]
- # Don't fail fast, so we get all of the detections
- reasons = list(checker.check_url(row.approved_url, fail_fast=False))
+ return True, f"Allowed as {rule} with hash {hex_hash}"
- try:
- # We expect a detection from not being on the allow list, so we'll
- # remove it, which will trigger a ValueError if it wasn't there
- reasons.remove(ALLOW_LIST_DETECTION)
- except ValueError:
- row.result = "Already allowed"
- continue
+ if response.status_code == 409:
+ return False, response.json()["errors"][0]["detail"]
- # After the expected allow list detection is gone, any remaining
- # reasons are because the URL is blocked
- if reasons:
- row.result = f"Detections found: {reasons}"
- else:
- rule, hex_hash = hash_for_rule(row.approved_url)
- row.result = f"Added to allow list as: '{rule}'"
+ if response.status_code == 404:
+ # If we ever sort out the permissiosns / principals stuff we'll get
+ # a nice 404 / 401 to be able to tell the difference
+ raise ConnectionError(
+ "Either your session has expired, or the route you have "
+ "provided is not correct"
+ )
- yield rule, hex_hash
-
-
-def create_sql(handle, rule_hashes, tags):
- """Write out the hashes into an SQL file for importing into Postgres."""
-
- handle.write("INSERT INTO allow_rule (rule, hash, tags)\nVALUES\n")
-
- tags = json.dumps(list(tags)).strip("[]")
- tags = f"{{{tags}}}"
-
- first = True
- for rule, hex_hash in rule_hashes:
- if first:
- first = False
- else:
- handle.write(",\n")
-
- handle.write(f"\t('{rule}', '{hex_hash}', '{tags}')")
-
- handle.write(";\n")
+ raise ConnectionError(
+ f"Unexpected error when connecting to checkmate: {response}: {response.content}"
+ )
def main():
@@ -153,28 +131,33 @@ def main():
if not os.path.isfile(args.input_csv):
raise EnvironmentError(f"Could not find expected file '{args.input_csv}'")
- # Check all the rows
-
+ checkmate = Checkmate(route=args.route, session=args.session)
rows = list(AllowListCSV.read(args.input_csv))
- config_file = resource_filename("checkmate", "../conf/development.ini")
- with bootstrap(config_file) as env:
- request = env["request"]
- checker = request.find_service(URLCheckerService)
+ changed = 0
+
+ for row in rows:
+ # This has already been dealt with
+ if row.result:
+ continue
- with request.tm:
- rule_hashes = list(check_rows(rows, checker))
+ changed += 1
+ rule_accepted, row.result = checkmate.allow_url(row.approved_url)
- # Create the output files
+ if rule_accepted:
+ print(f"Added row: {row}")
+ else:
+ print(f"Failed on row: {row}")
- with open(args.sql, "w") as handle:
- create_sql(handle, rule_hashes=rule_hashes, tags=["manual"])
+ if not changed:
+ print("No rows were altered. No CSV created")
+ return
+ # Create the output CSV file
with open(args.output_csv, "w") as handle:
AllowListCSV.write(handle, rows=rows)
- print(f"Created SQL file: {args.sql}")
- print(f"Creating CSV file: {args.output_csv}")
+ print(f"Created CSV file: {args.output_csv}")
if __name__ == "__main__":
diff --git a/checkmate/exceptions.py b/checkmate/exceptions.py
index 129cac84..caff3ddd 100644
--- a/checkmate/exceptions.py
+++ b/checkmate/exceptions.py
@@ -40,6 +40,18 @@ def serialise(self):
return data
+class ResourceConflict(JSONAPIException):
+ """The request cannot be completed as it conflicts with existing state."""
+
+ status_code = 409
+
+
+class MalformedJSONBody(JSONAPIException):
+ """The JSON body is malformed in some way."""
+
+ status_code = 400
+
+
class MalformedURL(Exception):
"""The URL is malformed in some way."""
diff --git a/checkmate/models/db/allow_rule.py b/checkmate/models/db/allow_rule.py
index af0c15e7..f4bda76d 100644
--- a/checkmate/models/db/allow_rule.py
+++ b/checkmate/models/db/allow_rule.py
@@ -4,10 +4,10 @@
from sqlalchemy.dialects.postgresql import ARRAY
from checkmate.db import BASE
-from checkmate.models.db.mixins import HashMatchMixin
+from checkmate.models.db.mixins import HashMatchMixin, JSONAPIMixin
-class AllowRule(BASE, HashMatchMixin):
+class AllowRule(BASE, HashMatchMixin, JSONAPIMixin):
"""Rule about allowing a particular resource."""
BULK_UPSERT_INDEX_ELEMENTS = ["rule"]
diff --git a/checkmate/models/db/mixins.py b/checkmate/models/db/mixins.py
index 93bf0e74..849d3422 100644
--- a/checkmate/models/db/mixins.py
+++ b/checkmate/models/db/mixins.py
@@ -117,3 +117,25 @@ def _bulk_upsert(cls, session, values, index_elements, update_elements):
# never commit the transaction we are working on and it will get rolled
# back
mark_changed(session)
+
+
+class JSONAPIMixin:
+ """A mixin for models to add JSON:API related functions."""
+
+ def to_json_api(self):
+ """Create a JSON:API resource dict for this object."""
+
+ if not self.id:
+ raise ValueError(
+ "An ID is mandatory to serialise an object in JSON:API. Have you flushed the DB?"
+ )
+
+ return {
+ "type": self.__class__.__name__,
+ "id": self.id,
+ "attributes": {
+ key: getattr(self, key)
+ for key in self.__table__.columns.keys()
+ if key != "id"
+ },
+ }
diff --git a/checkmate/resource/schema/AllowRule.json b/checkmate/resource/schema/AllowRule.json
new file mode 100644
index 00000000..1751ad4d
--- /dev/null
+++ b/checkmate/resource/schema/AllowRule.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "AllowRule",
+
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["data"],
+
+ "properties": {
+ "data": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "meta"],
+
+ "properties": {
+ "type": {"enum": ["AllowRule"]},
+
+ "meta": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["url"],
+
+ "properties": {
+ "url": {"type": "string", "format": "public-url"}
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/checkmate/routes.py b/checkmate/routes.py
index f852a7e2..01e44c8a 100644
--- a/checkmate/routes.py
+++ b/checkmate/routes.py
@@ -20,6 +20,8 @@ def add_routes(config):
config.add_route("login_callback", "/ui/api/login_callback")
config.add_route("logout", "/ui/api/logout")
+ config.add_route("add_to_allow_list", "/ui/api/rule", request_method="POST")
+
def includeme(config): # pragma: no cover
"""Pyramid config."""
diff --git a/checkmate/templates/admin/pages.html.jinja2 b/checkmate/templates/admin/pages.html.jinja2
index ed43dd1f..c854cb99 100644
--- a/checkmate/templates/admin/pages.html.jinja2
+++ b/checkmate/templates/admin/pages.html.jinja2
@@ -1,3 +1,9 @@
+
+
Hello {{ request.session.user.name }}
{% set user = request.session.user %}
@@ -13,5 +19,10 @@
Session
{{ request.session }}
+Add to allow list
+
+
+tox -qe dev --run-command "python bin/add_to_allow_list.py --session={{ session }} --route={{ request.route_url('add_to_allow_list') }}"
+
Logout
\ No newline at end of file
diff --git a/checkmate/validation.py b/checkmate/validation.py
new file mode 100644
index 00000000..aac04028
--- /dev/null
+++ b/checkmate/validation.py
@@ -0,0 +1,66 @@
+"""Validation tools for working with jsonschema."""
+
+import json
+
+from jsonschema import Draft7Validator, FormatChecker, ValidationError
+from pkg_resources import resource_stream
+
+from checkmate.exceptions import MalformedJSONBody
+from checkmate.url import CanonicalURL, Domain
+
+_FORMAT_CHECKER = FormatChecker()
+
+
+@_FORMAT_CHECKER.checks("public-url", raises=(ValueError,))
+def _check_public_url(instance):
+ """A validator which checks that a given URL is publically available.
+
+ This only uses static data, not an actual check online.
+ """
+ _, netloc, _, _, _, _ = CanonicalURL.canonical_split(instance)
+ domain = Domain(netloc)
+ if not domain.is_valid:
+ raise ValueError("The URL does not have a valid domain")
+
+ if not domain.is_public:
+ raise ValueError("The URL is not public")
+
+ return True
+
+
+def get_validator(schema_path):
+ """Get a jsonschema validator object for a given schema path.
+
+ :param schema_path: Path relative to the checkmate root
+ """
+
+ return Draft7Validator(
+ json.load(resource_stream("checkmate", schema_path)),
+ format_checker=_FORMAT_CHECKER,
+ )
+
+
+def get_validated_json_body(request, validator):
+ """Get the JSON body of a request validated against a jsonschema
+
+ :param request: Pyramid request object
+ :param validator: A jsonschema validator (see `get_validator()`)
+ :return: The json dict if validation is successful
+
+ :raise MalformedJSONBody: If the JSON cannot be decoded or the body
+ does not conform to the schema provided
+ """
+
+ try:
+ body = request.json_body
+ except ValueError as err:
+ raise MalformedJSONBody(f"Posted JSON missing or malformed: {err}") from err
+
+ try:
+ validator.validate(body)
+ except ValidationError as err:
+ raise MalformedJSONBody(
+ f"JSON body does not match expected schema: {err}"
+ ) from err
+
+ return body
diff --git a/checkmate/views/ui/admin.py b/checkmate/views/ui/admin.py
index c43d7654..9f0ebb99 100644
--- a/checkmate/views/ui/admin.py
+++ b/checkmate/views/ui/admin.py
@@ -1,4 +1,6 @@
"""User feedback for blocked pages."""
+from http.cookies import SimpleCookie
+
from pyramid.httpexceptions import HTTPFound
from pyramid.view import view_config
@@ -10,10 +12,13 @@
renderer="checkmate:templates/admin/pages.html.jinja2",
effective_principals=[Principals.STAFF],
)
-def admin_pages(_context, _request):
+def admin_pages(_context, request):
"""Render an HTML version of a blocked URL with explanation."""
- return {}
+ cookie = SimpleCookie()
+ cookie.load(request.headers["Cookie"])
+
+ return {"session": cookie["session"].value}
@view_config(route_name="admin_pages")
diff --git a/checkmate/views/ui/api/add_to_allow_list.py b/checkmate/views/ui/api/add_to_allow_list.py
new file mode 100644
index 00000000..6f34ed39
--- /dev/null
+++ b/checkmate/views/ui/api/add_to_allow_list.py
@@ -0,0 +1,53 @@
+"""User feedback for blocked pages."""
+
+from pyramid.view import view_config
+
+from checkmate.exceptions import ResourceConflict
+from checkmate.models import AllowRule, Detection, Principals, Reason, Source
+from checkmate.services import URLCheckerService
+from checkmate.url import hash_for_rule
+from checkmate.validation import get_validated_json_body, get_validator
+
+_ALLOW_LIST_DETECTION = Detection(Reason.NOT_ALLOWED, Source.ALLOW_LIST)
+_ALLOW_RULE_VALIDATOR = get_validator("resource/schema/AllowRule.json")
+
+
+@view_config(
+ route_name="add_to_allow_list",
+ request_method="POST",
+ renderer="json",
+ effective_principals=[Principals.STAFF],
+)
+def add_to_allow_list(_context, request):
+ """Render an HTML version of a blocked URL with explanation."""
+
+ body = get_validated_json_body(request, _ALLOW_RULE_VALIDATOR)
+ url = body["data"]["meta"]["url"]
+
+ # Check this isn't something really dumb like 'co.uk' which will ruin the
+ # allow list
+
+ # Don't fail fast, so we get all of the detections
+ checker = request.find_service(URLCheckerService)
+ reasons = list(checker.check_url(url, fail_fast=False))
+
+ try:
+ # We expect a detection from not being on the allow list, so we'll
+ # remove it, which will trigger a ValueError if it wasn't there
+ reasons.remove(_ALLOW_LIST_DETECTION)
+ except ValueError:
+ raise ResourceConflict("Requested URL is already allowed") from None
+
+ # After the expected allow list detection is gone, any remaining
+ # reasons are because the URL is blocked
+ if reasons:
+ raise ResourceConflict(f"Cannot allow URL as reasons to block found: {reasons}")
+
+ rule_string, hex_hash = hash_for_rule(url)
+
+ rule = AllowRule(rule=rule_string, hash=hex_hash, tags=["manual"])
+ request.db.add(rule)
+ request.db.flush() # Make sure an id is allocated before we serialise
+
+ # https://jsonapi.org/format/#document-top-level
+ return {"data": rule.to_json_api()}
diff --git a/requirements/dev.txt b/requirements/dev.txt
index f5b5081e..3bb7dcb4 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -6,7 +6,7 @@
#
alembic==1.4.3 # via -r requirements/requirements.txt
amqp==5.0.2 # via -r requirements/requirements.txt, kombu
-attrs==20.3.0 # via jsonschema
+attrs==20.3.0 # via -r requirements/requirements.txt, jsonschema
backcall==0.2.0 # via ipython
bcrypt==3.2.0 # via paramiko
billiard==3.6.3.0 # via -r requirements/requirements.txt, celery
@@ -41,7 +41,7 @@ ipython-genutils==0.2.0 # via traitlets
ipython==7.16.1 # via -r requirements/dev.in, ipdb, pyramid-ipython
jedi==0.17.2 # via ipython
jinja2==2.11.2 # via -r requirements/requirements.txt, pyramid-jinja2
-jsonschema==3.2.0 # via docker-compose
+jsonschema==3.2.0 # via -r requirements/requirements.txt, docker-compose
jwt==1.1.0 # via -r requirements/requirements.txt
kombu==5.0.2 # via -r requirements/requirements.txt, celery
mako==1.1.3 # via -r requirements/requirements.txt, alembic
@@ -71,7 +71,7 @@ pyramid-sanity==1.0.1 # via -r requirements/requirements.txt
pyramid-services==2.2 # via -r requirements/requirements.txt
pyramid-tm==2.4 # via -r requirements/requirements.txt
pyramid==1.10.5 # via -r requirements/requirements.txt, h-pyramid-sentry, pyramid-exclog, pyramid-ipython, pyramid-jinja2, pyramid-sanity, pyramid-services, pyramid-tm
-pyrsistent==0.17.3 # via jsonschema
+pyrsistent==0.17.3 # via -r requirements/requirements.txt, jsonschema
python-dateutil==2.8.1 # via -r requirements/requirements.txt, alembic, faker
python-dotenv==0.15.0 # via docker-compose
python-editor==1.0.4 # via -r requirements/requirements.txt, alembic
diff --git a/requirements/functests.txt b/requirements/functests.txt
index 046a1949..37aabdb6 100644
--- a/requirements/functests.txt
+++ b/requirements/functests.txt
@@ -6,7 +6,7 @@
#
alembic==1.4.3 # via -r requirements/requirements.txt
amqp==5.0.2 # via -r requirements/requirements.txt, kombu
-attrs==20.3.0 # via pytest
+attrs==20.3.0 # via -r requirements/requirements.txt, jsonschema, pytest
beautifulsoup4==4.9.3 # via webtest
billiard==3.6.3.0 # via -r requirements/requirements.txt, celery
cachetools==4.2.1 # via -r requirements/requirements.txt, google-auth
@@ -25,10 +25,11 @@ h-matchers==1.2.10 # via -r requirements/functests.in
h-pyramid-sentry==1.2.1 # via -r requirements/requirements.txt
hupper==1.10.2 # via -r requirements/requirements.txt, pyramid
idna==2.10 # via -r requirements/requirements.txt, requests
-importlib-metadata==3.1.0 # via -r requirements/requirements.txt, kombu, pluggy, pytest
+importlib-metadata==3.1.0 # via -r requirements/requirements.txt, jsonschema, kombu, pluggy, pytest
importlib-resources==3.3.0 # via -r requirements/requirements.txt, netaddr
iniconfig==1.1.1 # via pytest
jinja2==2.11.2 # via -r requirements/requirements.txt, pyramid-jinja2
+jsonschema==3.2.0 # via -r requirements/requirements.txt
jwt==1.1.0 # via -r requirements/requirements.txt
kombu==5.0.2 # via -r requirements/requirements.txt, celery
mako==1.1.3 # via -r requirements/requirements.txt, alembic
@@ -54,6 +55,7 @@ pyramid-sanity==1.0.1 # via -r requirements/requirements.txt
pyramid-services==2.2 # via -r requirements/requirements.txt
pyramid-tm==2.4 # via -r requirements/requirements.txt
pyramid==1.10.5 # via -r requirements/requirements.txt, h-pyramid-sentry, pyramid-exclog, pyramid-jinja2, pyramid-sanity, pyramid-services, pyramid-tm
+pyrsistent==0.17.3 # via -r requirements/requirements.txt, jsonschema
pytest==6.2.1 # via -r requirements/functests.in
python-dateutil==2.8.1 # via -r requirements/requirements.txt, alembic
python-editor==1.0.4 # via -r requirements/requirements.txt, alembic
@@ -62,7 +64,7 @@ requests-oauthlib==1.3.0 # via -r requirements/requirements.txt, google-auth-oa
requests==2.25.0 # via -r requirements/requirements.txt, requests-oauthlib
rsa==4.7 # via -r requirements/requirements.txt, google-auth
sentry-sdk==0.19.4 # via -r requirements/requirements.txt, h-pyramid-sentry
-six==1.15.0 # via -r requirements/requirements.txt, click-repl, cryptography, google-auth, python-dateutil, webtest
+six==1.15.0 # via -r requirements/requirements.txt, click-repl, cryptography, google-auth, jsonschema, python-dateutil, webtest
soupsieve==2.1 # via beautifulsoup4
sqlalchemy==1.3.20 # via -r requirements/requirements.txt, alembic, zope.sqlalchemy
toml==0.10.2 # via pytest
diff --git a/requirements/lint.txt b/requirements/lint.txt
index af1db3fb..361ca86e 100644
--- a/requirements/lint.txt
+++ b/requirements/lint.txt
@@ -7,7 +7,7 @@
alembic==1.4.3 # via -r requirements/requirements.txt, -r requirements/tests.txt
amqp==5.0.2 # via -r requirements/requirements.txt, -r requirements/tests.txt, kombu
astroid==2.4.2 # via pylint
-attrs==20.3.0 # via -r requirements/tests.txt, pytest
+attrs==20.3.0 # via -r requirements/requirements.txt, -r requirements/tests.txt, jsonschema, pytest
beautifulsoup4==4.9.3 # via -r requirements/tests.txt, webtest
billiard==3.6.3.0 # via -r requirements/requirements.txt, -r requirements/tests.txt, celery
cachetools==4.2.1 # via -r requirements/requirements.txt, -r requirements/tests.txt, google-auth
@@ -31,11 +31,12 @@ htmlmin==0.1.12 # via -r requirements/lint.in
httpretty==1.0.3 # via -r requirements/tests.txt
hupper==1.10.2 # via -r requirements/requirements.txt, -r requirements/tests.txt, pyramid
idna==2.10 # via -r requirements/requirements.txt, -r requirements/tests.txt, requests
-importlib-metadata==3.1.0 # via -r requirements/requirements.txt, -r requirements/tests.txt, kombu, pluggy, pytest
+importlib-metadata==3.1.0 # via -r requirements/requirements.txt, -r requirements/tests.txt, jsonschema, kombu, pluggy, pytest
importlib-resources==3.3.0 # via -r requirements/requirements.txt, -r requirements/tests.txt, netaddr
iniconfig==1.1.1 # via -r requirements/tests.txt, pytest
isort==5.6.4 # via pylint
jinja2==2.11.2 # via -r requirements/requirements.txt, -r requirements/tests.txt, pyramid-jinja2
+jsonschema==3.2.0 # via -r requirements/requirements.txt, -r requirements/tests.txt
jwt==1.1.0 # via -r requirements/requirements.txt, -r requirements/tests.txt
kombu==5.0.2 # via -r requirements/requirements.txt, -r requirements/tests.txt, celery
lazy-object-proxy==1.4.3 # via astroid
@@ -65,6 +66,7 @@ pyramid-sanity==1.0.1 # via -r requirements/requirements.txt, -r requirement
pyramid-services==2.2 # via -r requirements/requirements.txt, -r requirements/tests.txt
pyramid-tm==2.4 # via -r requirements/requirements.txt, -r requirements/tests.txt
pyramid==1.10.5 # via -r requirements/requirements.txt, -r requirements/tests.txt, h-pyramid-sentry, pyramid-exclog, pyramid-jinja2, pyramid-sanity, pyramid-services, pyramid-tm
+pyrsistent==0.17.3 # via -r requirements/requirements.txt, -r requirements/tests.txt, jsonschema
pytest==6.1.2 # via -r requirements/tests.txt
python-dateutil==2.8.1 # via -r requirements/requirements.txt, -r requirements/tests.txt, alembic, faker
python-editor==1.0.4 # via -r requirements/requirements.txt, -r requirements/tests.txt, alembic
@@ -75,7 +77,7 @@ requests==2.25.0 # via -r requirements/requirements.txt, -r requirement
rjsmin==1.1.0 # via -r requirements/lint.in
rsa==4.7 # via -r requirements/requirements.txt, -r requirements/tests.txt, google-auth
sentry-sdk==0.19.4 # via -r requirements/requirements.txt, -r requirements/tests.txt, h-pyramid-sentry
-six==1.15.0 # via -r requirements/requirements.txt, -r requirements/tests.txt, astroid, click-repl, cryptography, google-auth, packaging, python-dateutil, webtest
+six==1.15.0 # via -r requirements/requirements.txt, -r requirements/tests.txt, astroid, click-repl, cryptography, google-auth, jsonschema, packaging, python-dateutil, webtest
snowballstemmer==2.0.0 # via pydocstyle
soupsieve==2.0.1 # via -r requirements/tests.txt, beautifulsoup4
sqlalchemy==1.3.20 # via -r requirements/requirements.txt, -r requirements/tests.txt, alembic, zope.sqlalchemy
diff --git a/requirements/requirements.in b/requirements/requirements.in
index 65937348..828c7b12 100644
--- a/requirements/requirements.in
+++ b/requirements/requirements.in
@@ -2,6 +2,7 @@ alembic
celery
google-auth-oauthlib
gunicorn
+jsonschema
jwt
psycopg2
pyramid
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 472f235f..5e369300 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -6,6 +6,7 @@
#
alembic==1.4.3 # via -r requirements/requirements.in
amqp==5.0.2 # via kombu
+attrs==20.3.0 # via jsonschema
billiard==3.6.3.0 # via celery
cachetools==4.2.1 # via google-auth
celery==5.0.2 # via -r requirements/requirements.in
@@ -22,9 +23,10 @@ gunicorn==20.0.4 # via -r requirements/requirements.in
h-pyramid-sentry==1.2.1 # via -r requirements/requirements.in
hupper==1.10.2 # via pyramid
idna==2.10 # via requests
-importlib-metadata==3.1.0 # via kombu
+importlib-metadata==3.1.0 # via jsonschema, kombu
importlib-resources==3.3.0 # via netaddr
jinja2==2.11.2 # via pyramid-jinja2
+jsonschema==3.2.0 # via -r requirements/requirements.in
jwt==1.1.0 # via -r requirements/requirements.in
kombu==5.0.2 # via celery
mako==1.1.3 # via alembic
@@ -46,6 +48,7 @@ pyramid-sanity==1.0.1 # via -r requirements/requirements.in
pyramid-services==2.2 # via -r requirements/requirements.in
pyramid-tm==2.4 # via -r requirements/requirements.in
pyramid==1.10.5 # via -r requirements/requirements.in, h-pyramid-sentry, pyramid-exclog, pyramid-jinja2, pyramid-sanity, pyramid-services, pyramid-tm
+pyrsistent==0.17.3 # via jsonschema
python-dateutil==2.8.1 # via alembic
python-editor==1.0.4 # via alembic
pytz==2020.4 # via celery
@@ -53,7 +56,7 @@ requests-oauthlib==1.3.0 # via google-auth-oauthlib
requests==2.25.0 # via -r requirements/requirements.in, requests-oauthlib
rsa==4.7 # via google-auth
sentry-sdk==0.19.4 # via h-pyramid-sentry
-six==1.15.0 # via click-repl, cryptography, google-auth, python-dateutil
+six==1.15.0 # via click-repl, cryptography, google-auth, jsonschema, python-dateutil
sqlalchemy==1.3.20 # via -r requirements/requirements.in, alembic, zope.sqlalchemy
transaction==3.0.0 # via pyramid-tm, zope.sqlalchemy
translationstring==1.4 # via pyramid
diff --git a/requirements/tests.txt b/requirements/tests.txt
index 45b0d144..e84b514b 100644
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -6,7 +6,7 @@
#
alembic==1.4.3 # via -r requirements/requirements.txt
amqp==5.0.2 # via -r requirements/requirements.txt, kombu
-attrs==20.3.0 # via pytest
+attrs==20.3.0 # via -r requirements/requirements.txt, jsonschema, pytest
beautifulsoup4==4.9.3 # via webtest
billiard==3.6.3.0 # via -r requirements/requirements.txt, celery
cachetools==4.2.1 # via -r requirements/requirements.txt, google-auth
@@ -29,10 +29,11 @@ h-pyramid-sentry==1.2.1 # via -r requirements/requirements.txt
httpretty==1.0.3 # via -r requirements/tests.in
hupper==1.10.2 # via -r requirements/requirements.txt, pyramid
idna==2.10 # via -r requirements/requirements.txt, requests
-importlib-metadata==3.1.0 # via -r requirements/requirements.txt, kombu, pluggy, pytest
+importlib-metadata==3.1.0 # via -r requirements/requirements.txt, jsonschema, kombu, pluggy, pytest
importlib-resources==3.3.0 # via -r requirements/requirements.txt, netaddr
iniconfig==1.1.1 # via pytest
jinja2==2.11.2 # via -r requirements/requirements.txt, pyramid-jinja2
+jsonschema==3.2.0 # via -r requirements/requirements.txt
jwt==1.1.0 # via -r requirements/requirements.txt
kombu==5.0.2 # via -r requirements/requirements.txt, celery
mako==1.1.3 # via -r requirements/requirements.txt, alembic
@@ -58,6 +59,7 @@ pyramid-sanity==1.0.1 # via -r requirements/requirements.txt
pyramid-services==2.2 # via -r requirements/requirements.txt
pyramid-tm==2.4 # via -r requirements/requirements.txt
pyramid==1.10.5 # via -r requirements/requirements.txt, h-pyramid-sentry, pyramid-exclog, pyramid-jinja2, pyramid-sanity, pyramid-services, pyramid-tm
+pyrsistent==0.17.3 # via -r requirements/requirements.txt, jsonschema
pytest==6.1.2 # via -r requirements/tests.in
python-dateutil==2.8.1 # via -r requirements/requirements.txt, alembic, faker
python-editor==1.0.4 # via -r requirements/requirements.txt, alembic
@@ -66,7 +68,7 @@ requests-oauthlib==1.3.0 # via -r requirements/requirements.txt, google-auth-oa
requests==2.25.0 # via -r requirements/requirements.txt, requests-oauthlib
rsa==4.7 # via -r requirements/requirements.txt, google-auth
sentry-sdk==0.19.4 # via -r requirements/requirements.txt, h-pyramid-sentry
-six==1.15.0 # via -r requirements/requirements.txt, click-repl, cryptography, google-auth, packaging, python-dateutil, webtest
+six==1.15.0 # via -r requirements/requirements.txt, click-repl, cryptography, google-auth, jsonschema, packaging, python-dateutil, webtest
soupsieve==2.0.1 # via beautifulsoup4
sqlalchemy==1.3.20 # via -r requirements/requirements.txt, alembic, zope.sqlalchemy
text-unidecode==1.3 # via faker