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

Open
wants to merge 16 commits into
base: refac
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
LadyCodesItBetter marked this conversation as resolved.
Show resolved Hide resolved
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.

Copy link
Member

Choose a reason for hiding this comment

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

yeah, absolutely. We cannot rely on the relative path of tha file distributed within a python package.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

To address the issue of accessibility and management of the config.yml file, I propose two possible solutions to enhance flexibility and simplify the use of the library:

  • dynamic configurations via env of configuration file (a mechanism to dynamically specify the configuration file path using an environment variable, such as PYEUDIW_CONFIG_PATH)
  • inline configuration support (allow configuration to be passed directly as a Python dictionary or JSON object during class initialization)

@peppelinux which do you think would work better for our use case?

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The PresentationSubmission class processes and validates presentation submissions. Its handlers attribute is a dictionary mapping indices from the descriptor_map in the submission to dynamically instantiated handler objects. These handlers are responsible for specific operations (e.g., validation or transformation) tied to the format defined in a configuration file (config.yml).

The handlers are not just namespaces for static methods. They are instances of dynamically loaded classes based on the descriptor formats, designed to process, validate, or manage specific data. Each handler can implement complex logic and maintain state, depending on the requirements of the associated format. This approach ensures modularity, configurability, and extensibility, far beyond the role of a simple placeholder.

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]
LadyCodesItBetter marked this conversation as resolved.
Show resolved Hide resolved

@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
LadyCodesItBetter marked this conversation as resolved.
Show resolved Hide resolved
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)