Initial implementation of validator

This is the initial implementation of a jsonschema type
validator for the Keystone API. Something very similar to this is used
in Nova V3 for validation. By using jsonschema to validate API requests,
we can make parameters that are passed in fit the criteria we allow and
what is specified in the Identify API spec. This will also allow us to
validate parameters by wrapping the method that needs validation.

bp: api-validation
Change-Id: I1e1dc8e5ac3ad766f05444b16d56a22c89602b9f
This commit is contained in:
Lance Bragstad 2014-02-27 04:17:35 +00:00
parent 90b3e94b21
commit 95850c01c1
6 changed files with 329 additions and 0 deletions

View File

@ -0,0 +1,38 @@
# 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.
"""Request body validating middleware for OpenStack Identity resources."""
import functools
from keystone.common.validation import validators
def validated(request_body_schema, resource_to_validate):
"""Register a schema to validate a resource reference.
Registered schema will be used for validating a request body just before
API method execution.
:param request_body_schema: a schema to validate the resource reference
:param resource_to_validate: the reference to validate
"""
schema_validator = validators.SchemaValidator(request_body_schema)
def add_validator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if resource_to_validate in kwargs:
schema_validator.validate(kwargs[resource_to_validate])
return func(*args, **kwargs)
return wrapper
return add_validator

View File

@ -0,0 +1,63 @@
# 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.
"""Common parameter types for validating a request reference."""
boolean = {
'type': 'boolean',
'enum': [True, False]
}
# NOTE(lbragstad): Be mindful of this pattern as it might require changes
# once this is used on user names, LDAP-based user names specifically since
# commas aren't allowed in the following pattern. Here we are only going to
# check the length of the name and ensure that it's a string. Right now we are
# not going to validate on a naming pattern for issues with
# internationalization.
name = {
'type': 'string',
'minLength': 1,
'maxLength': 255
}
hex_uuid = {
'type': 'string',
'maxLength': 32,
'minLength': 32,
'pattern': '^[a-fA-F0-9]*$'
}
description = {
'type': ['string', 'null']
}
url = {
'type': 'string',
'minLength': 0,
'maxLength': 225,
# NOTE(lbragstad): Using a regular expression here instead of the
# FormatChecker object that is built into jsonschema. The FormatChecker
# can validate URI formats but it depends on rfc3987 to do that
# validation, and rfc3987 is GPL licensed. For our purposes here we will
# use a regex and not rely on rfc3987 to validate URIs.
'pattern': '^https?://'
'(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)'
'+[a-zA-Z]{2,6}\.?|'
'localhost|'
'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
'(?::\d+)?'
'(?:/?|[/?]\S+)$'
}
email = {
'type': 'string',
'format': 'email'
}

View File

@ -0,0 +1,61 @@
# 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 body validating middleware."""
import jsonschema
from keystone import exception
from keystone.i18n import _
class SchemaValidator(object):
"""Resource reference validator class."""
validator = None
validator_org = jsonschema.Draft4Validator
def __init__(self, schema):
# NOTE(lbragstad): If at some point in the future we want to extend
# our validators to include something specific we need to check for,
# we can do it here. Nova's V3 API validators extend the validator to
# include `self._validate_minimum` and `self._validate_maximum`. This
# would be handy if we needed to check for something the jsonschema
# didn't by default. See the Nova V3 validator for details on how this
# is done.
validators = {}
validator_cls = jsonschema.validators.extend(self.validator_org,
validators)
fc = jsonschema.FormatChecker()
self.validator = validator_cls(schema, format_checker=fc)
def validate(self, *args, **kwargs):
try:
self.validator.validate(*args, **kwargs)
except jsonschema.ValidationError as ex:
# NOTE: For whole OpenStack message consistency, this error
# message has been written in a format consistent with WSME.
if len(ex.path) > 0:
# NOTE(lbragstad): Here we could think about using iter_errors
# as a method of providing invalid parameters back to the
# user.
# TODO(lbragstad): If the value of a field is confidential or
# too long, then we should build the masking in here so that
# we don't expose sensitive user information in the event it
# fails validation.
detail = _("Invalid input for field '%(path)s'. The value is "
"'%(value)s'.") % {
'path': ex.path.pop(),
'value': ex.instance
}
else:
detail = ex.message
raise exception.SchemaValidationError(detail=detail)

View File

@ -84,6 +84,12 @@ class ValidationError(Error):
title = 'Bad Request'
class SchemaValidationError(ValidationError):
# NOTE(lbragstad): For whole OpenStack message consistency, this error
# message has been written in a format consistent with WSME.
message_format = _("%(detail)s")
class ValidationTimeStampError(Error):
message_format = _("Timestamp not in expected format."
" The server could not comply with the request"

View File

@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
# 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.
import uuid
from keystone.common.validation import parameter_types
from keystone.common.validation import validators
from keystone import exception
from keystone import tests
# Test schema to validate create requests against
_CREATE = {
'type': 'object',
'properties': {
'name': parameter_types.name,
'description': parameter_types.description,
'enabled': parameter_types.boolean,
'url': parameter_types.url,
'email': parameter_types.email
},
'required': ['name'],
'additionalProperties': True,
}
class ValidationTestCase(tests.TestCase):
def setUp(self):
super(ValidationTestCase, self).setUp()
self.resource_name = 'some resource name'
self.description = 'Some valid description'
self.valid_enabled = True
self.valid_url = 'http://example.com'
self.valid_email = 'joe@example.com'
self.create_schema_validator = validators.SchemaValidator(_CREATE)
def test_create_schema_with_all_valid_parameters(self):
"""Validate proper values against test schema."""
request_to_validate = {'name': self.resource_name,
'some_uuid': uuid.uuid4().hex,
'description': self.description,
'enabled': self.valid_enabled,
'url': self.valid_url}
self.create_schema_validator.validate(request_to_validate)
def test_create_schema_with_name_too_long_raises_exception(self):
"""Validate long names.
Validate that an exception is raised when validating a string of 255+
characters passed in as a name.
"""
invalid_name = ''
for i in range(255):
invalid_name = invalid_name + str(i)
request_to_validate = {'name': invalid_name}
self.assertRaises(exception.SchemaValidationError,
self.create_schema_validator.validate,
request_to_validate)
def test_create_schema_with_name_too_short_raises_exception(self):
"""Validate short names.
Test that an exception is raised when passing a string of length
zero as a name parameter.
"""
request_to_validate = {'name': ''}
self.assertRaises(exception.SchemaValidationError,
self.create_schema_validator.validate,
request_to_validate)
def test_create_schema_with_unicode_name_is_successful(self):
"""Test that we successfully validate a unicode string."""
request_to_validate = {'name': u'αβγδ'}
self.create_schema_validator.validate(request_to_validate)
def test_create_schema_with_invalid_enabled_format_raises_exception(self):
"""Validate invalid enabled formats.
Test that an exception is raised when passing invalid boolean-like
values as `enabled`.
"""
invalid_enabled_formats = 'some string'
request_to_validate = {'name': self.resource_name,
'enabled': invalid_enabled_formats}
self.assertRaises(exception.SchemaValidationError,
self.create_schema_validator.validate,
request_to_validate)
def test_create_schema_with_valid_enabled_formats(self):
"""Validate valid enabled formats.
Test that we have successful validation on boolean values for
`enabled`.
"""
valid_enabled_formats = [True, False]
for valid_enabled in valid_enabled_formats:
request_to_validate = {'name': self.resource_name,
'enabled': valid_enabled}
# Make sure validation doesn't raise a validation exception
self.create_schema_validator.validate(request_to_validate)
def test_create_schema_with_valid_urls(self):
"""Test that proper urls are successfully validated."""
valid_urls = ['https://169.254.0.1', 'https://example.com',
'https://EXAMPLE.com', 'https://127.0.0.1:35357',
'https://localhost']
for valid_url in valid_urls:
request_to_validate = {'name': self.resource_name,
'url': valid_url}
self.create_schema_validator.validate(request_to_validate)
def test_create_schema_with_invalid_urls(self):
"""Test that an exception is raised when validating improper urls."""
invalid_urls = ['http//something.com',
'https//something.com',
'https://9.9.9']
for invalid_url in invalid_urls:
request_to_validate = {'name': self.resource_name,
'url': invalid_url}
self.assertRaises(exception.SchemaValidationError,
self.create_schema_validator.validate,
request_to_validate)
def test_create_schema_with_valid_email(self):
"""Validate email address
Test that we successfully validate properly formatted email
addresses.
"""
request_to_validate = {'name': self.resource_name,
'email': self.valid_email}
self.create_schema_validator.validate(request_to_validate)
def test_create_schema_with_invalid_email(self):
"""Validate invalid email address
Test that an exception is raised when validating improperly
formatted email addresses.
"""
request_to_validate = {'name': self.resource_name,
'email': 'some invalid email value'}
self.assertRaises(exception.SchemaValidationError,
self.create_schema_validator.validate,
request_to_validate)

View File

@ -27,6 +27,7 @@ commands =
keystone/tests/test_singular_plural.py \
keystone/tests/test_sizelimit.py \
keystone/tests/test_token_bind.py \
keystone/tests/test_validation.py \
keystone/tests/unit
[testenv:pep8]