Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement allowlist framework for dependencies #1443

Merged
merged 14 commits into from
Aug 12, 2024
1 change: 1 addition & 0 deletions gateway/api/v1/allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Tansito marked this conversation as resolved.
Show resolved Hide resolved
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:
psschwei marked this conversation as resolved.
Show resolved Hide resolved
for d in deps:
dep, ver = d.split("==")
Tansito marked this conversation as resolved.
Show resolved Hide resolved

# 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