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

#299-feature/presentation submission dynamic handler #314

Draft
wants to merge 11 commits into
base: refac
Choose a base branch
from
Draft
Empty file.
15 changes: 15 additions & 0 deletions pyeudiw/openid4vp/presentation_submission/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
formats:
- name: "dc+sd-jwt"
module: "pyeudiw.openid4vp.presentation_submission"
class: "VcSdJwt"
- name: "ldp_vp"
module: "pyeudiw.openid4vp.presentation_submission"
class: "LdpVp"
- name: "jwt_vp_json"
module: "pyeudiw.openid4vp.presentation_submission"
class: "JwtVpJson"
- name: "ac_vp"
module: "pyeudiw.openid4vp.presentation_submission"
class: "AcVp"

MAX_SUBMISSION_SIZE: 10 * 1024 * 1024
LadyCodesItBetter marked this conversation as resolved.
Show resolved Hide resolved
121 changes: 121 additions & 0 deletions pyeudiw/openid4vp/presentation_submission/presentation_submission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import os
from pydantic import ValidationError
import yaml
import importlib
from typing import Dict, Any
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing Dict is deprecated since Python 3.9 and this project officially supports Python 3.10+ only - use dict instead (no import required).
https://docs.python.org/3/library/typing.html#aliases-to-built-in-types

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

go ahead using dict without any import

import logging

from pyeudiw.openid4vp.presentation_submission.schemas import PresentationSubmissionSchema

logger = logging.getLogger(__name__)

class PresentationSubmission:
def __init__(self, submission: Dict[str, Any]):
"""
Initialize the PresentationSubmission handler with the submission data.

Args:
submission (Dict[str, Any]): The presentation submission data.

Raises:
KeyError: If the 'format' key is missing in the submission.
ValueError: If the format is not supported or not defined in the configuration.
ImportError: If the module or class cannot be loaded.
ValidationError: If the submission data is invalid or exceeds size limits.
"""
self.config = self._load_config()
self.submission = self._validate_submission(submission)
self.handlers = self._initialize_handlers()

def _load_config(self) -> Dict[str, Any]:
"""
Load the configuration from format_config.yml located in the same directory.

Returns:
Dict[str, Any]: The configuration dictionary.

Raises:
FileNotFoundError: If the configuration file is not found.
"""
config_path = os.path.join(os.path.dirname(__file__), "config.yml")
Copy link
Collaborator

@Zicchio Zicchio Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of having a class configurable by editing an obscure configuration file in the installed class project that is available who-knows-where based on what pip (or other build tool) is doing and might possibly be inside a container? Just use a config.py file at this point - it is functionally the same thing.

@peppelinux @LadyCodesItBetter I'm not sure here what is the intended design goal.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the intended propose is for a generic python package, not necessarly used in the iam proxy context

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point still stands. Assume I am using the project as a package, that is, I am using it with pip install pyeudiw as a part of my project.
To change the configuration of this class, I have to go in the location where pip placed config.yaml (which is usually inside the site-packages) and and edit it. This is IMO very convoluted and only doable by technically advanced users.

if not os.path.exists(config_path):
raise FileNotFoundError(f"Configuration file not found: {config_path}")

with open(config_path, "r") as config_file:
return yaml.safe_load(config_file)

def _validate_submission(self, submission: Dict[str, Any]) -> PresentationSubmissionSchema:
"""
Validate the submission data using Pydantic and check its total size.

Args:
submission (Dict[str, Any]): The presentation submission data.

Returns:
PresentationSubmissionSchema: Validated submission schema.

Raises:
ValidationError: If the submission data is invalid or exceeds size limits.
"""
max_size = self.config.get("MAX_SUBMISSION_SIZE", 10 * 1024 * 1024)

# Check submission size
submission_size = len(str(submission).encode("utf-8"))
if submission_size > max_size:
logger.warning(
f"Rejected submission: size {submission_size} bytes exceeds limit {max_size} bytes."
)
raise ValueError(
f"Submission size exceeds maximum allowed limit of {max_size} bytes."
)

try:
return PresentationSubmissionSchema(**submission)
except ValidationError as e:
logger.error(f"Submission validation failed: {e}")
raise
peppelinux marked this conversation as resolved.
Show resolved Hide resolved
def _initialize_handlers(self) -> Dict[int, object]:
"""
Initialize handlers for each item in the 'descriptor_map' of the submission.

Returns:
Dict[int, object]: A dictionary mapping indices to handler instances.

Raises:
KeyError: If the 'format' key is missing in any descriptor.
ValueError: If a format is not supported or not defined in the configuration.
ImportError: If a module or class cannot be loaded.
"""
handlers = {}

try:
descriptor_map = self.submission.descriptor_map
except KeyError:
raise KeyError("The 'descriptor_map' key is missing in the submission.")

for index, descriptor in enumerate(descriptor_map):
format_name = descriptor.format
if not format_name:
raise KeyError(f"The 'format' key is missing in descriptor at index {index}.")

# Search for the format in the configuration
format_conf = next((fmt for fmt in self.config.get("formats", []) if fmt["name"] == format_name), None)
if not format_conf:
raise ValueError(f"Format '{format_name}' is not supported or not defined in the configuration.")

module_name = format_conf["module"]
class_name = format_conf["class"]

try:
# Dynamically load the module and class
module = importlib.import_module(module_name)
cls = getattr(module, class_name)
handlers[index] = cls() # Instantiate the class
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK the fact that each handler is a class initialized with an empty constructor implies that handlers are functionally just namespace for static methods. It's impossible to known what they should do without doing introspection on what aforementioned namespace contains.

except ModuleNotFoundError:
logger.warning(f"Module '{module_name}' not found for format '{format_name}'. Skipping index {index}.")
except AttributeError:
logger.warning(f"Class '{class_name}' not found in module '{module_name}' for format '{format_name}'. Skipping index {index}.")
except Exception as e:
logger.warning(f"Error loading format '{format_name}' for index {index}: {e}")

return handlers
23 changes: 23 additions & 0 deletions pyeudiw/openid4vp/presentation_submission/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Any, Dict, List
from pydantic import BaseModel, field_validator


class DescriptorSchema(BaseModel):
id: str
format: str
path: str
path_nested: Dict[str, Any] = None


class PresentationSubmissionSchema(BaseModel):
id: str
definition_id: str
descriptor_map: List[DescriptorSchema]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List is also deprecated: use list instead

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

go ahead using list without any import


@field_validator("descriptor_map")
@classmethod
def check_descriptor_map_size(cls, value):
max_descriptors = 100 # TODO: Define a reasonable limit
if len(value) > max_descriptors:
raise ValueError(f"descriptor_map exceeds maximum allowed size of {max_descriptors} items.")
return value
14 changes: 1 addition & 13 deletions pyeudiw/openid4vp/schemas/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,7 @@
from pydantic import BaseModel, field_validator

from pyeudiw.jwt.utils import is_jwt_format


class DescriptorSchema(BaseModel):
id: str
path: str
format: str


class PresentationSubmissionSchema(BaseModel):
definition_id: str
id: str
descriptor_map: list[DescriptorSchema]

from pyeudiw.openid4vp.presentation_submission.schemas import PresentationSubmissionSchema

class ResponseSchema(BaseModel):
state: Optional[str]
Expand Down
121 changes: 121 additions & 0 deletions pyeudiw/tests/openid4vp/presentation_submission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import pytest
Copy link
Collaborator

@Zicchio Zicchio Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe I'm wrong but if a test file name does not start with test_ or ends with _test.py then pytest will NOT execute it for unit tests (altough ci-cd pipeline might change this behaviour - I'm not 100% sure how unit tests are configured here).

In doubt, this file should be named test_presentation_submission.py
https://docs.pytest.org/en/stable/explanation/goodpractices.html#test-discovery

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can confirm that _test doesn't get loaded

from unittest.mock import patch, MagicMock
from pydantic import ValidationError
from pyeudiw.openid4vp.presentation_submission.presentation_submission import PresentationSubmission


# Mock data for testing
mock_format_config = {
"formats": [
{"name": "ldp_vp", "module": "mock.module", "class": "MockLdpVpHandler"},
{"name": "jwt_vp_json", "module": "mock.module", "class": "MockJwtVpJsonHandler"}
],
"MAX_SUBMISSION_SIZE": 10 * 1024 # 10 KB
}

valid_submission = {
"id": "submission_id",
"definition_id": "definition_id",
"descriptor_map": [
{"id": "descriptor_1", "format": "ldp_vp", "path": "$"},
{"id": "descriptor_2", "format": "jwt_vp_json", "path": "$"}
]
}

large_submission = {
"id": "submission_id_large",
"definition_id": "definition_id_large",
"descriptor_map": [{"id": f"descriptor_{i}", "format": "ldp_vp", "path": "$"} for i in range(101)] # Exceeds limit
}


def test_presentation_submission_initialization_with_schema_validation():
"""
Test that the PresentationSubmission class initializes correctly
and validates against the Pydantic schema.
"""
# Mock handler classes
mock_ldp_vp_handler = MagicMock(name="MockLdpVpHandler")
mock_jwt_vp_json_handler = MagicMock(name="MockJwtVpJsonHandler")

# Mock import_module to return a fake module with our mock classes
mock_module = MagicMock()
setattr(mock_module, "MockLdpVpHandler", mock_ldp_vp_handler)
setattr(mock_module, "MockJwtVpJsonHandler", mock_jwt_vp_json_handler)

with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config), \
patch("importlib.import_module", return_value=mock_module):

# Initialize the class
ps = PresentationSubmission(valid_submission)

# Assert that handlers were created for all formats in descriptor_map
assert len(ps.handlers) == len(valid_submission["descriptor_map"]), "Not all handlers were created."

# Check that the handlers are instances of the mocked classes
assert ps.handlers[0] is mock_ldp_vp_handler(), "Handler for 'ldp_vp' format is incorrect."
assert ps.handlers[1] is mock_jwt_vp_json_handler(), "Handler for 'jwt_vp_json' format is incorrect."


def test_presentation_submission_large_submission_with_schema():
"""
Test that the PresentationSubmission class raises a ValidationError
when the submission exceeds the descriptor_map size limit.
"""
with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config):
# Expect a ValidationError for exceeding descriptor_map size limit
with pytest.raises(ValidationError, match="descriptor_map exceeds maximum allowed size of 100 items"):
PresentationSubmission(large_submission)


def test_presentation_submission_missing_descriptor_key():
"""
Test that the PresentationSubmission class raises a ValidationError
when required keys are missing in the descriptor_map.
"""
invalid_submission = {
"id": "invalid_submission_id",
"definition_id": "invalid_definition_id",
"descriptor_map": [
{"format": "ldp_vp"}
]
}

with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config):

with pytest.raises(ValidationError, match=r"Field required"):
PresentationSubmission(invalid_submission)

def test_presentation_submission_invalid_format():
"""
Test that the PresentationSubmission class raises a ValueError
when an unsupported format is encountered.
"""
invalid_submission = {
"id": "invalid_submission_id",
"definition_id": "invalid_definition_id",
"descriptor_map": [
{"format": "unsupported_format", "id": "descriptor_1", "path": "$"}
]
}

with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config):
with pytest.raises(ValueError, match="Format 'unsupported_format' is not supported or not defined in the configuration."):
PresentationSubmission(invalid_submission)

def test_presentation_submission_missing_format_key():
"""
Test that the PresentationSubmission class raises a KeyError
when the 'format' key is missing in a descriptor.
"""
missing_format_key_submission = {
"id": "missing_format_submission_id",
"definition_id": "missing_format_definition_id",
"descriptor_map": [
{"id": "descriptor_1", "path": "$"} # Missing 'format' key
]
}

with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config):
with pytest.raises(ValidationError, match=r"descriptor_map\.0\.format\s+Field required"):
PresentationSubmission(missing_format_key_submission)