diff --git a/ironic/api/controllers/v1/shard.py b/ironic/api/controllers/v1/shard.py index 71c91a12d2..5835481d53 100644 --- a/ironic/api/controllers/v1/shard.py +++ b/ironic/api/controllers/v1/shard.py @@ -18,6 +18,7 @@ from ironic import api from ironic.api.controllers.v1 import utils as api_utils from ironic.api.controllers.v1 import versions from ironic.api import method +from ironic.api.schemas.v1 import shard as schema from ironic.api import validation from ironic.common.i18n import _ @@ -36,6 +37,8 @@ class ShardController(pecan.rest.RestController): min_version=versions.MINOR_82_NODE_SHARD, message=_('The API version does not allow shards'), ) + @validation.request_query_schema(schema.index_request_query) + @validation.response_body_schema(schema.index_response_body) def get_all(self): """Retrieve a list of shards. @@ -53,6 +56,6 @@ class ShardController(pecan.rest.RestController): min_version=versions.MINOR_82_NODE_SHARD, message=_('The API version does not allow shards'), ) - def get_one(self, __): + def get_one(self, _): """Explicitly do not support getting one.""" pecan.abort(404) diff --git a/ironic/api/schemas/v1/shard.py b/ironic/api/schemas/v1/shard.py new file mode 100644 index 0000000000..e293e70485 --- /dev/null +++ b/ironic/api/schemas/v1/shard.py @@ -0,0 +1,39 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# TODO(stephenfin): Switch additionalProperties to False in a future version +index_request_query = { + 'type': 'object', + 'properties': {}, + 'required': [], + 'additionalProperties': True, +} + +index_response_body = { + 'type': 'object', + 'properties': { + 'shards': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'count': {'type': 'integer', 'minimum': 1}, + 'name': {'type': 'string'}, + }, + 'required': ['count', 'name'], + 'additionalProperties': False, + }, + }, + }, + 'required': ['shards'], + 'additionalProperties': False, +} diff --git a/ironic/api/validation/__init__.py b/ironic/api/validation/__init__.py index e0c45831f3..ed21affd71 100644 --- a/ironic/api/validation/__init__.py +++ b/ironic/api/validation/__init__.py @@ -10,12 +10,17 @@ # License for the specific language governing permissions and limitations # under the License. +"""API request/response validating middleware.""" + import functools +import inspect import typing as ty +from oslo_serialization import jsonutils from webob import exc as webob_exc from ironic import api +from ironic.api.validation import validators from ironic.common.i18n import _ @@ -70,3 +75,291 @@ def api_version( return wrapper return add_validator + + +class Schemas: + """A microversion-aware schema container. + + Allow definition and retrieval of schemas on a microversion-aware basis. + """ + + def __init__(self) -> None: + self._schemas: list[ + tuple[dict[str, object], ty.Optional[int], ty.Optional[int]] + ] = [] + + def add_schema( + self, + schema: tuple[dict[str, object]], + min_version: ty.Optional[int], + max_version: ty.Optional[int], + ) -> None: + self._schemas.append((schema, min_version, max_version)) + + def __call__(self) -> ty.Optional[dict[str, object]]: + for schema, min_version, max_version in self._schemas: + if ( + min_version and not api.request.version.minor >= min_version + ) or ( + max_version and not api.request.version.minor <= max_version + ): + continue + + return schema + + return None + + +def _schema_validator( + schema: ty.Dict[str, ty.Any], + target: ty.Dict[str, ty.Any], + min_version: ty.Optional[int], + max_version: ty.Optional[int], + is_body: bool = True, +): + """A helper method to execute JSON Schema Validation. + + This method checks the request version whether matches the specified + ``max_version`` and ``min_version``. If the version range matches the + request, we validate ``schema`` against ``target``. A failure will result + in ``ValidationError`` being raised. + + :param schema: The JSON Schema schema used to validate the target. + :param target: The target to be validated by the schema. + :param min_version: An integer indicating the minimum API version + ``schema`` applies against. + :param max_version: An integer indicating the maximum API version + ``schema`` applies against. + :param args: Positional arguments which passed into original method. + :param kwargs: Keyword arguments which passed into original method. + :param is_body: Whether ``target`` is a HTTP request body or not. + :returns: None. + :raises: ``ValidationError`` if validation fails. + """ + # Only validate against the schema if it lies within + # the version range specified. Note that if both min + # and max are not specified the validator will always + # be run. + if ( + (min_version and api.request.version.minor < min_version) + or (max_version and api.request.version.minor > max_version) + ): + return + + schema_validator = validators.SchemaValidator(schema, is_body=is_body) + schema_validator.validate(target) + + +def _extract_parameters(function): + sig = inspect.signature(function) + params = [] + + for param in sig.parameters.values(): + if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + if param.name == 'self': # skip validating self + continue + + params.append(param) + return params + + +def request_parameter_schema( + schema: ty.Dict[str, ty.Any], + min_version: ty.Optional[int] = None, + max_version: ty.Optional[int] = None, +): + """Decorator for registering a request parameter schema on API methods. + + ``schema`` will be used for validating request parameters just before + the API method is executed. + + :param schema: The JSON Schema schema used to validate the target. + :param min_version: An integer indicating the minimum API version + ``schema`` applies against. + :param max_version: An integer indicating the maximum API version + ``schema`` applies against. + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # we need to convert positional arguments to a dict mapping token + # name to value so that we have a reference to compare against + parameters = _extract_parameters(func) + if func.__name__ in ('patch', 'post'): + # if this a create or update method, we need to ignore the + # request body parameter + parameters = parameters[:-1] + + parameters = { + p.name: args[i + 1] for i, p in enumerate(parameters) + if p.name != '_' and p.default is p.empty + } + _schema_validator( + schema, + parameters, + min_version, + max_version, + is_body=True, + ) + return func(*args, **kwargs) + + if not hasattr(wrapper, 'request_parameter_schemas'): + wrapper.request_parameter_schemas = Schemas() + + wrapper.request_parameter_schemas .add_schema( + schema, min_version, max_version + ) + + return wrapper + + return add_validator + + +def request_query_schema( + schema: ty.Dict[str, ty.Any], + min_version: ty.Optional[int] = None, + max_version: ty.Optional[int] = None, +): + """Decorator for registering a request query string schema on API methods. + + ``schema`` will be used for validating request query strings just before + the API method is executed. + + :param schema: The JSON Schema schema used to validate the target. + :param min_version: An integer indicating the minimum API version + ``schema`` applies against. + :param max_version: An integer indicating the maximum API version + ``schema`` applies against. + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + _schema_validator( + schema, + kwargs, + min_version, + max_version, + is_body=True, + ) + return func(*args, **kwargs) + + if not hasattr(wrapper, 'request_query_schemas'): + wrapper.request_query_schemas = Schemas() + + wrapper.request_query_schemas .add_schema( + schema, min_version, max_version + ) + + return wrapper + + return add_validator + + +def request_body_schema( + schema: ty.Dict[str, ty.Any], + min_version: ty.Optional[str] = None, + max_version: ty.Optional[str] = None, +): + """Decorator for registering a request body schema on API methods. + + ``schema`` will be used for validating the request body just before the API + method is executed. + + :param schema: The JSON Schema schema used to validate the target. + :param min_version: A string indicating the minimum API version ``schema`` + applies against. + :param max_version: A string indicating the maximum API version ``schema`` + applies against. + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + parameters = _extract_parameters(func) + if not parameters: + # TODO(stephenfin): this would be a better check if we + # distinguished between 'create' operations (which should have + # at least one parameter, the body) and "update" operations + # (which should have at least two, the IDs and the body) + raise RuntimeError( + 'The ironic.api.method.body decorator must be placed ' + 'outside the validation helpers to ensure it runs first.' + ) + + _schema_validator( + schema, + # The body argument will always be the last one + kwargs[parameters[-1].name], + min_version, + max_version, + is_body=True, + ) + return func(*args, **kwargs) + + if not hasattr(wrapper, 'request_body_schemas'): + wrapper.request_body_schemas = Schemas() + + wrapper.request_body_schemas .add_schema( + schema, min_version, max_version + ) + + return wrapper + + return add_validator + + +def response_body_schema( + schema: ty.Dict[str, ty.Any], + min_version: ty.Optional[str] = None, + max_version: ty.Optional[str] = None, +): + """Decorator for registering a response body schema on API methods. + + ``schema`` will be used for validating the response body just after the API + method is executed. + + :param schema: The JSON Schema schema used to validate the target. + :param min_version: A string indicating the minimum API version ``schema`` + applies against. + :param max_version: A string indicating the maximum API version ``schema`` + applies against. + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + response = func(*args, **kwargs) + + # FIXME(stephenfin): How is ironic/pecan doing jsonification? The + # below will fail on e.g. date-time fields + + # NOTE(stephenfin): If our response is an object, we need to + # serialize and deserialize to convert e.g. date-time to strings + _body = jsonutils.dumps(response) + + if _body == b'': + body = None + else: + body = jsonutils.loads(_body) + + _schema_validator( + schema, + body, + min_version, + max_version, + is_body=True, + ) + return response + + if not hasattr(wrapper, 'response_body_schemas'): + wrapper.response_body_schemas = Schemas() + + wrapper.response_body_schemas .add_schema( + schema, min_version, max_version + ) + + return wrapper + + return add_validator diff --git a/ironic/api/validation/validators.py b/ironic/api/validation/validators.py new file mode 100644 index 0000000000..a2f6d509be --- /dev/null +++ b/ironic/api/validation/validators.py @@ -0,0 +1,121 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Internal implementation of request/response validating middleware.""" + +import jsonschema +from jsonschema import exceptions as jsonschema_exc +from oslo_utils import timeutils +from oslo_utils import uuidutils + +from ironic.common import exception +from ironic.common.i18n import _ + + +@jsonschema.FormatChecker.cls_checks('date-time') +def _validate_datetime_format(instance: object) -> bool: + # format checks constrain to the relevant primitive type + # https://github.com/OAI/OpenAPI-Specification/issues/3148 + if not isinstance(instance, str): + return True + try: + timeutils.parse_isotime(instance) + except ValueError: + return False + else: + return True + + +@jsonschema.FormatChecker.cls_checks('uuid') +def _validate_uuid_format(instance: object) -> bool: + # format checks constrain to the relevant primitive type + # https://github.com/OAI/OpenAPI-Specification/issues/3148 + if not isinstance(instance, str): + return True + + return uuidutils.is_uuid_like(instance) + + +class FormatChecker(jsonschema.FormatChecker): + """A FormatChecker can output the message from cause exception + + We need understandable validation errors messages for users. When a + custom checker has an exception, the FormatChecker will output a + readable message provided by the checker. + """ + + def check(self, param_value, format): + """Check whether the param_value conforms to the given format. + + :param param_value: the param_value to check + :type: any primitive type (str, number, bool) + :param str format: the format that param_value should conform to + :raises: :exc:`FormatError` if param_value does not conform to format + """ + + if format not in self.checkers: + return + + # For safety reasons custom checkers can be registered with + # allowed exception types. Anything else will fall into the + # default formatter. + func, raises = self.checkers[format] + result, cause = None, None + + try: + result = func(param_value) + except raises as e: + cause = e + if not result: + msg = '%r is not a %r' % (param_value, format) + raise jsonschema_exc.FormatError(msg, cause=cause) + + +class SchemaValidator: + """A validator class + + This class is changed from Draft202012Validator to add format checkers for + data formats that are common in the Ironic API as well as add better error + messages. + """ + + validator = None + validator_org = jsonschema.Draft202012Validator + + def __init__( + self, schema, is_body=True + ): + self.is_body = is_body + validator_cls = jsonschema.validators.extend(self.validator_org) + format_checker = FormatChecker() + try: + self.validator = validator_cls( + schema, format_checker=format_checker + ) + except Exception: + raise + + def validate(self, *args, **kwargs): + try: + self.validator.validate(*args, **kwargs) + except jsonschema.ValidationError as e: + error_msg = _('Schema error: %s') % e.message + # Sometimes the root message is too generic, try to find a possible + # root cause: + cause = None + current = e + while current.context: + current = jsonschema.exceptions.best_match(current.context) + cause = current.message + if cause is not None: + error_msg += _('. Possible root cause: %s') % cause + raise exception.InvalidParameterValue(error_msg) diff --git a/ironic/tests/unit/api/validation/__init__.py b/ironic/tests/unit/api/validation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/api/validation/test_validators.py b/ironic/tests/unit/api/validation/test_validators.py new file mode 100644 index 0000000000..da3c1d79c2 --- /dev/null +++ b/ironic/tests/unit/api/validation/test_validators.py @@ -0,0 +1,46 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from ironic.api.validation import validators +from ironic.common import exception +from ironic.tests import base as test_base + + +class TestSchemaValidator(test_base.TestCase): + + def test_uuid_format(self): + schema = {'type': 'string', 'format': 'uuid'} + validator = validators.SchemaValidator(schema) + + # passes + validator.validate('d1903ad5-c774-4bfe-8cf4-8e08d8dbb4d3') + + # fails + self.assertRaises( + exception.InvalidParameterValue, + validator.validate, + 'invalid uuid' + ) + + def test_datetime_format(self): + schema = {'type': 'string', 'format': 'date-time'} + validator = validators.SchemaValidator(schema) + + # passes + validator.validate('2019-10-12T07:20:50.52Z') + + # fails + self.assertRaises( + exception.InvalidParameterValue, + validator.validate, + 'invalid date-time' + ) diff --git a/releasenotes/notes/add_schema_validation_framework-eaac62cfecb132b0.yaml b/releasenotes/notes/add_schema_validation_framework-eaac62cfecb132b0.yaml new file mode 100644 index 0000000000..f7e486420b --- /dev/null +++ b/releasenotes/notes/add_schema_validation_framework-eaac62cfecb132b0.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Adds a schema validation framework to the API. This allows for the validation + of incoming requests and outgoing responses against a JSON schema, right at + the beginning and end of the request processing pipeline. \ No newline at end of file