Skip to content

Commit

Permalink
Implement allowlist framework for dependencies (#1443)
Browse files Browse the repository at this point in the history
* implement allowlist framework for dependencies

Signed-off-by: Paul S. Schweigert <[email protected]>

Allow operators to specify an allowlist of dependencies and allowed
versions.

The allowlist is stored in a config file.

A sample allowlist might look like:

    allowlist = { "wheel": ["0.44.0", "0.43.2"] }

which would imply that the wheel package is allowed, but only versions
0.43.2 and 0.44.0 .

For this PR, the allowlist is empty, which means all dependencies are
allowed, and it is stored locally. In a future PR, this should move
into a Kubernetes configmap so that it can be updated without having
to rebuild the container.

Each dependency can optionally specify a list of allowed versions. If
the list is empty, then all versions of the dependency are allowed.

An example:

    allowlist = { "wheel": [] }

This could also be adapted to store a minimum allowed version instead
of listing all allowed versions, depending on requirements.

* option to allowlist all versions of dependency

Signed-off-by: Paul S. Schweigert <[email protected]>

* lint

Signed-off-by: Paul S. Schweigert <[email protected]>

* specify encoding

Signed-off-by: Paul S. Schweigert <[email protected]>

* use envvar for allowlist config

Signed-off-by: Paul S. Schweigert <[email protected]>

* lint

Signed-off-by: Paul S. Schweigert <[email protected]>

* add tests for gateway allowlist functionality

Signed-off-by: Paul S. Schweigert <[email protected]>

* lint

Signed-off-by: Paul S. Schweigert <[email protected]>

* review comments

Signed-off-by: Paul S. Schweigert <[email protected]>

* lint

Signed-off-by: Paul S. Schweigert <[email protected]>

* catch and log errors opening/decoding allowlist

Signed-off-by: Paul S. Schweigert <[email protected]>

* lint

Signed-off-by: Paul S. Schweigert <[email protected]>

* lint again

Signed-off-by: Paul S. Schweigert <[email protected]>

* lint 3

Signed-off-by: Paul S. Schweigert <[email protected]>

---------

Signed-off-by: Paul S. Schweigert <[email protected]>
  • Loading branch information
psschwei authored Aug 12, 2024
1 parent c9ac30c commit 932e9f9
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 1 deletion.
1 change: 1 addition & 0 deletions gateway/api/v1/allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
40 changes: 39 additions & 1 deletion gateway/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
Serializers api for V1.
"""

import json
import logging
from rest_framework.serializers import ValidationError
from django.conf import settings
from api import serializers
from api.models import Provider

logger = logging.getLogger("gateway.serializers")


class ProgramSerializer(serializers.ProgramSerializer):
"""
Expand Down Expand Up @@ -34,7 +39,7 @@ def validate_image(self, value):
# place to add image validation
return value

def validate(self, attrs):
def validate(self, attrs): # pylint: disable=too-many-branches
"""Validates serializer data."""
entrypoint = attrs.get("entrypoint", None)
image = attrs.get("image", None)
Expand All @@ -43,6 +48,39 @@ def validate(self, attrs):
"At least one of attributes (entrypoint, image) is required."
)

# validate dependencies
# allowlist stored in json config file (eventually via configmap)
# sample:
# allowlist = { "wheel": ["0.44.0", "0.43.2"] }
# where the values for each key are allowed versions of dependency
deps = json.loads(attrs.get("dependencies", None))
try:
with open(
settings.GATEWAY_ALLOWLIST_CONFIG, encoding="utf-8", mode="r"
) as f:
allowlist = json.load(f)
except IOError as e:
logger.error("Unable to open allowlist config file: %s", e)
raise ValueError("Unable to open allowlist config file") from e
except ValueError as e:
logger.error("Unable to decode dependency allowlist: %s", e)
raise ValueError("Unable to decode dependency allowlist") from e

# If no allowlist specified, all dependencies allowed
if len(allowlist.keys()) > 0:
for d in deps:
dep, ver = d.split("==")

# Determine if a dependency is allowed
if dep not in allowlist:
raise ValidationError(f"Dependency {dep} is not allowed")

# Determine if a specific version of a dependency is allowed
if allowlist[dep] and ver not in allowlist[dep]:
raise ValidationError(
f"Version {ver} of dependency {dep} is not allowed"
)

title = attrs.get("title")
provider = attrs.get("provider", None)
if provider and "/" in title:
Expand Down
4 changes: 4 additions & 0 deletions gateway/main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,10 @@

PROGRAM_TIMEOUT = int(os.environ.get("PROGRAM_TIMEOUT", "14"))

GATEWAY_ALLOWLIST_CONFIG = str(
os.environ.get("GATEWAY_ALLOWLIST_CONFIG", "api/v1/allowlist.json")
)

# qiskit runtime
QISKIT_IBM_CHANNEL = os.environ.get("QISKIT_IBM_CHANNEL", "ibm_quantum")
QISKIT_IBM_URL = os.environ.get(
Expand Down
4 changes: 4 additions & 0 deletions gateway/tests/api/test_allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"wheel": ["1.0.0"],
"pendulum": []
}
104 changes: 104 additions & 0 deletions gateway/tests/api/test_v1_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,107 @@ def test_upload_program_serializer_with_only_title(self):
["At least one of attributes (entrypoint, image) is required."],
[value[0] for value in errors.values()],
)

def test_upload_program_serializer_allowed_dependencies(self):
"""Tests dependency allowlist."""

print("TEST: Program succeeds if all dependencies are allowlisted")

path_to_resource_artifact = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"resources",
"artifact.tar",
)
file_data = File(open(path_to_resource_artifact, "rb"))
upload_file = SimpleUploadedFile(
"artifact.tar", file_data.read(), content_type="multipart/form-data"
)

user = models.User.objects.get(username="test_user")

title = "Hello world"
entrypoint = "pattern.py"
arguments = "{}"
dependencies = '["wheel==1.0.0","pendulum==1.2.3"]'

data = {}
data["title"] = title
data["entrypoint"] = entrypoint
data["arguments"] = arguments
data["dependencies"] = dependencies
data["artifact"] = upload_file

serializer = UploadProgramSerializer(data=data)
self.assertTrue(serializer.is_valid())

program: Program = serializer.save(author=user)
self.assertEqual(title, program.title)
self.assertEqual(entrypoint, program.entrypoint)
self.assertEqual(dependencies, program.dependencies)

def test_upload_program_serializer_blocked_dependency(self):
"""Tests dependency allowlist."""

print("TEST: Upload fails if dependency isn't allowlisted")

path_to_resource_artifact = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"resources",
"artifact.tar",
)
file_data = File(open(path_to_resource_artifact, "rb"))
upload_file = SimpleUploadedFile(
"artifact.tar", file_data.read(), content_type="multipart/form-data"
)

user = models.User.objects.get(username="test_user")

title = "Hello world"
entrypoint = "pattern.py"
arguments = "{}"
dependencies = '["setuptools==0.4.1"]'

data = {}
data["title"] = title
data["entrypoint"] = entrypoint
data["arguments"] = arguments
data["dependencies"] = dependencies
data["artifact"] = upload_file

serializer = UploadProgramSerializer(data=data)
self.assertFalse(serializer.is_valid())

def test_upload_program_serializer_dependency_bad_version(self):
"""Tests dependency allowlist."""

print("TEST: Upload fails if dependency version isn't allowlisted")

path_to_resource_artifact = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"resources",
"artifact.tar",
)
file_data = File(open(path_to_resource_artifact, "rb"))
upload_file = SimpleUploadedFile(
"artifact.tar", file_data.read(), content_type="multipart/form-data"
)

user = models.User.objects.get(username="test_user")

title = "Hello world"
entrypoint = "pattern.py"
arguments = "{}"
dependencies = '["wheel==0.4.1"]'

data = {}
data["title"] = title
data["entrypoint"] = entrypoint
data["arguments"] = arguments
data["dependencies"] = dependencies
data["artifact"] = upload_file

serializer = UploadProgramSerializer(data=data)
self.assertFalse(serializer.is_valid())
1 change: 1 addition & 0 deletions gateway/tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ setenv =
LANGUAGE=en_US
LC_ALL=en_US.utf-8
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
GATEWAY_ALLOWLIST_CONFIG=tests/api/test_allowlist.json
deps = -rrequirements.txt
-rrequirements-dev.txt
commands =
Expand Down

0 comments on commit 932e9f9

Please sign in to comment.