Merge "Enable JsonSchema validation for project"

This commit is contained in:
Zuul 2024-10-07 13:53:24 +00:00 committed by Gerrit Code Review
commit f7ffacb7ad
5 changed files with 195 additions and 56 deletions

View File

@ -17,10 +17,11 @@ import http.client
import flask import flask
from keystone.api import validation
from keystone.common import json_home from keystone.common import json_home
from keystone.common import provider_api from keystone.common import provider_api
from keystone.common import rbac_enforcer from keystone.common import rbac_enforcer
from keystone.common import validation from keystone.common import validation as ks_validation
import keystone.conf import keystone.conf
from keystone import exception from keystone import exception
from keystone.i18n import _ from keystone.i18n import _
@ -54,6 +55,7 @@ class ProjectResource(ks_flask.ResourceBase):
) )
def _expand_project_ref(self, ref): def _expand_project_ref(self, ref):
# NOTE(gtema): This functionality is not described in the API-Ref
parents_as_list = self.query_filter_is_true('parents_as_list') parents_as_list = self.query_filter_is_true('parents_as_list')
parents_as_ids = self.query_filter_is_true('parents_as_ids') parents_as_ids = self.query_filter_is_true('parents_as_ids')
@ -100,7 +102,9 @@ class ProjectResource(ks_flask.ResourceBase):
) )
) )
def _get_project(self, project_id): @validation.request_body_schema(None)
@validation.response_body_schema(schema.project_get_response_body)
def get(self, project_id: str):
"""Get project. """Get project.
GET/HEAD /v3/projects/{project_id} GET/HEAD /v3/projects/{project_id}
@ -113,7 +117,51 @@ class ProjectResource(ks_flask.ResourceBase):
self._expand_project_ref(project) self._expand_project_ref(project)
return self.wrap_member(project) return self.wrap_member(project)
def _list_projects(self): @validation.request_body_schema(schema.project_update_request_body)
@validation.response_body_schema(schema.project_update_response_body)
def patch(self, project_id):
"""Update project.
PATCH /v3/projects/{project_id}
"""
ENFORCER.enforce_call(
action='identity:update_project',
build_target=_build_project_target_enforcement,
)
project = self.request_body_json.get('project', {})
self._require_matching_id(project)
ref = PROVIDERS.resource_api.update_project(
project_id, project, initiator=self.audit_initiator
)
return self.wrap_member(ref)
@validation.request_body_schema(None)
@validation.response_body_schema(None)
def delete(self, project_id):
"""Delete project.
DELETE /v3/projects/{project_id}
"""
ENFORCER.enforce_call(
action='identity:delete_project',
build_target=_build_project_target_enforcement,
)
PROVIDERS.resource_api.delete_project(
project_id, initiator=self.audit_initiator
)
return None, http.client.NO_CONTENT
class ProjectsResource(ks_flask.ResourceBase):
collection_key = 'projects'
member_key = 'project'
get_member_from_driver = PROVIDERS.deferred_provider_lookup(
api='resource_api', method='get_project'
)
@validation.request_query_schema(schema.project_index_request_query)
@validation.response_body_schema(schema.project_index_response_body)
def get(self):
"""List projects. """List projects.
GET/HEAD /v3/projects GET/HEAD /v3/projects
@ -148,17 +196,8 @@ class ProjectResource(ks_flask.ResourceBase):
filtered_refs = refs filtered_refs = refs
return self.wrap_collection(filtered_refs, hints=hints) return self.wrap_collection(filtered_refs, hints=hints)
def get(self, project_id=None): @validation.request_body_schema(schema.project_create_request_body)
"""Get project or list projects. @validation.response_body_schema(schema.project_create_response_body)
GET/HEAD /v3/projects
GET/HEAD /v3/projects/{project_id}
"""
if project_id is not None:
return self._get_project(project_id)
else:
return self._list_projects()
def post(self): def post(self):
"""Create project. """Create project.
@ -169,7 +208,6 @@ class ProjectResource(ks_flask.ResourceBase):
ENFORCER.enforce_call( ENFORCER.enforce_call(
action='identity:create_project', target_attr=target action='identity:create_project', target_attr=target
) )
validation.lazy_validate(schema.project_create, project)
project = self._assign_unique_id(project) project = self._assign_unique_id(project)
if not project.get('is_domain'): if not project.get('is_domain'):
project = self._normalize_domain_id(project) project = self._normalize_domain_id(project)
@ -187,37 +225,6 @@ class ProjectResource(ks_flask.ResourceBase):
raise exception.ValidationError(e) raise exception.ValidationError(e)
return self.wrap_member(ref), http.client.CREATED return self.wrap_member(ref), http.client.CREATED
def patch(self, project_id):
"""Update project.
PATCH /v3/projects/{project_id}
"""
ENFORCER.enforce_call(
action='identity:update_project',
build_target=_build_project_target_enforcement,
)
project = self.request_body_json.get('project', {})
validation.lazy_validate(schema.project_update, project)
self._require_matching_id(project)
ref = PROVIDERS.resource_api.update_project(
project_id, project, initiator=self.audit_initiator
)
return self.wrap_member(ref)
def delete(self, project_id):
"""Delete project.
DELETE /v3/projects/{project_id}
"""
ENFORCER.enforce_call(
action='identity:delete_project',
build_target=_build_project_target_enforcement,
)
PROVIDERS.resource_api.delete_project(
project_id, initiator=self.audit_initiator
)
return None, http.client.NO_CONTENT
class _ProjectTagResourceBase(ks_flask.ResourceBase): class _ProjectTagResourceBase(ks_flask.ResourceBase):
collection_key = 'projects' collection_key = 'projects'
@ -237,6 +244,8 @@ class _ProjectTagResourceBase(ks_flask.ResourceBase):
class ProjectTagsResource(_ProjectTagResourceBase): class ProjectTagsResource(_ProjectTagResourceBase):
@validation.request_body_schema(None)
@validation.response_body_schema(schema.tags_response_body)
def get(self, project_id): def get(self, project_id):
"""List tags associated with a given project. """List tags associated with a given project.
@ -249,6 +258,8 @@ class ProjectTagsResource(_ProjectTagResourceBase):
ref = PROVIDERS.resource_api.list_project_tags(project_id) ref = PROVIDERS.resource_api.list_project_tags(project_id)
return self.wrap_member(ref) return self.wrap_member(ref)
@validation.request_body_schema(schema.tags_update_request_body)
@validation.response_body_schema(schema.tags_response_body)
def put(self, project_id): def put(self, project_id):
"""Update all tags associated with a given project. """Update all tags associated with a given project.
@ -259,12 +270,14 @@ class ProjectTagsResource(_ProjectTagResourceBase):
build_target=_build_project_target_enforcement, build_target=_build_project_target_enforcement,
) )
tags = self.request_body_json.get('tags', {}) tags = self.request_body_json.get('tags', {})
validation.lazy_validate(schema.project_tags_update, tags) ks_validation.lazy_validate(schema.project_tags_update, tags)
ref = PROVIDERS.resource_api.update_project_tags( ref = PROVIDERS.resource_api.update_project_tags(
project_id, tags, initiator=self.audit_initiator project_id, tags, initiator=self.audit_initiator
) )
return self.wrap_member(ref) return self.wrap_member(ref)
@validation.request_body_schema(None)
@validation.response_body_schema(None)
def delete(self, project_id): def delete(self, project_id):
"""Delete all tags associated with a given project. """Delete all tags associated with a given project.
@ -279,6 +292,8 @@ class ProjectTagsResource(_ProjectTagResourceBase):
class ProjectTagResource(_ProjectTagResourceBase): class ProjectTagResource(_ProjectTagResourceBase):
@validation.request_body_schema(None)
@validation.response_body_schema(None)
def get(self, project_id, value): def get(self, project_id, value):
"""Get information for a single tag associated with a given project. """Get information for a single tag associated with a given project.
@ -291,6 +306,8 @@ class ProjectTagResource(_ProjectTagResourceBase):
PROVIDERS.resource_api.get_project_tag(project_id, value) PROVIDERS.resource_api.get_project_tag(project_id, value)
return None, http.client.NO_CONTENT return None, http.client.NO_CONTENT
@validation.request_body_schema(None)
@validation.response_body_schema(None)
def put(self, project_id, value): def put(self, project_id, value):
"""Add a single tag to a project. """Add a single tag to a project.
@ -300,11 +317,11 @@ class ProjectTagResource(_ProjectTagResourceBase):
action='identity:create_project_tag', action='identity:create_project_tag',
build_target=_build_project_target_enforcement, build_target=_build_project_target_enforcement,
) )
validation.lazy_validate(schema.project_tag_create, value) ks_validation.lazy_validate(schema.project_tag_create, value)
# Check if we will exceed the max number of tags on this project # Check if we will exceed the max number of tags on this project
tags = PROVIDERS.resource_api.list_project_tags(project_id) tags = PROVIDERS.resource_api.list_project_tags(project_id)
tags.append(value) tags.append(value)
validation.lazy_validate(schema.project_tags_update, tags) ks_validation.lazy_validate(schema.project_tags_update, tags)
PROVIDERS.resource_api.create_project_tag( PROVIDERS.resource_api.create_project_tag(
project_id, value, initiator=self.audit_initiator project_id, value, initiator=self.audit_initiator
) )
@ -313,6 +330,8 @@ class ProjectTagResource(_ProjectTagResourceBase):
response.headers['Location'] = url response.headers['Location'] = url
return response return response
@validation.request_body_schema(None)
@validation.response_body_schema(None)
def delete(self, project_id, value): def delete(self, project_id, value):
"""Delete a single tag from a project. """Delete a single tag from a project.
@ -566,8 +585,21 @@ class ProjectGroupListGrantResource(_ProjectGrantResourceBase):
class ProjectAPI(ks_flask.APIBase): class ProjectAPI(ks_flask.APIBase):
_name = 'projects' _name = 'projects'
_import_name = __name__ _import_name = __name__
resources = [ProjectResource]
resource_mapping = [ resource_mapping = [
ks_flask.construct_resource_map(
resource=ProjectsResource,
url='/projects',
resource_kwargs={},
rel="projects",
path_vars=None,
),
ks_flask.construct_resource_map(
resource=ProjectResource,
url='/projects/<string:project_id>',
resource_kwargs={},
rel="project",
path_vars={'project_id': json_home.Parameters.PROJECT_ID},
),
ks_flask.construct_resource_map( ks_flask.construct_resource_map(
resource=ProjectTagsResource, resource=ProjectTagsResource,
url='/projects/<string:project_id>/tags', url='/projects/<string:project_id>/tags',

View File

@ -13,6 +13,8 @@
"""Common parameter types for validating API requests.""" """Common parameter types for validating API requests."""
from typing import Any from typing import Any
empty: dict[str, Any] = {"type": "null"}
name: dict[str, Any] = { name: dict[str, Any] = {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,

View File

@ -39,3 +39,12 @@ truncated: dict[str, Any] = {
"response limit" "response limit"
), ),
} }
tags: dict[str, Any] = {
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": "string"}},
"links": links,
},
"additionalProperties": False,
}

View File

@ -9,9 +9,12 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from typing import Any
from keystone.api.validation import parameter_types
from keystone.api.validation import response_types
from keystone.common import validation from keystone.common import validation
from keystone.common.validation import parameter_types from keystone.common.validation import parameter_types as old_parameter_types
from keystone.resource.backends import resource_options as ro from keystone.resource.backends import resource_options as ro
_name_properties = { _name_properties = {
@ -41,12 +44,12 @@ _project_tags_list_properties = {
} }
_project_properties = { _project_properties = {
'description': validation.nullable(parameter_types.description), 'description': validation.nullable(old_parameter_types.description),
# NOTE(htruta): domain_id is nullable for projects acting as a domain. # NOTE(htruta): domain_id is nullable for projects acting as a domain.
'domain_id': validation.nullable(parameter_types.id_string), 'domain_id': validation.nullable(old_parameter_types.id_string),
'enabled': parameter_types.boolean, 'enabled': parameter_types.boolean,
'is_domain': parameter_types.boolean, 'is_domain': parameter_types.boolean,
'parent_id': validation.nullable(parameter_types.id_string), 'parent_id': validation.nullable(old_parameter_types.id_string),
'name': _name_properties, 'name': _name_properties,
'tags': _project_tags_list_properties, 'tags': _project_tags_list_properties,
'options': ro.PROJECT_OPTIONS_REGISTRY.json_schema, 'options': ro.PROJECT_OPTIONS_REGISTRY.json_schema,
@ -77,7 +80,7 @@ project_update = {
} }
_domain_properties = { _domain_properties = {
'description': validation.nullable(parameter_types.description), 'description': validation.nullable(old_parameter_types.description),
'enabled': parameter_types.boolean, 'enabled': parameter_types.boolean,
'name': _name_properties, 'name': _name_properties,
'tags': project_tags_update, 'tags': project_tags_update,
@ -99,3 +102,91 @@ domain_update = {
'minProperties': 1, 'minProperties': 1,
'additionalProperties': True, 'additionalProperties': True,
} }
project_index_request_query = {
'type': 'object',
'properties': {
"domain_id": parameter_types.domain_id,
"enabled": parameter_types.boolean,
"name": _name_properties,
"parent_id": parameter_types.parent_id,
"is_domain": parameter_types.boolean,
"tags": parameter_types.tags,
"tags-any": parameter_types.tags,
"not-tags": parameter_types.tags,
"not-tags-any": parameter_types.tags,
},
}
project_schema: dict[str, Any] = {
"type": "object",
"properties": {
"id": {"type": "string", "readOnly": True},
"links": response_types.resource_links,
**_project_properties,
},
"additionalProperties": True,
}
project_get_response_body: dict[str, Any] = {
"type": "object",
"properties": {"project": project_schema},
"additionalProperties": False,
}
project_index_response_body: dict[str, Any] = {
"type": "object",
"properties": {
"projects": {"type": "array", "items": project_schema},
"links": response_types.links,
"truncated": response_types.truncated,
},
"additionalProperties": False,
}
project_create_request_body: dict[str, Any] = {
"type": "object",
"properties": {
"project": {
"type": "object",
"properties": _project_properties,
"required": ["name"],
}
},
"additionalProperties": False,
}
project_create_response_body: dict[str, Any] = project_get_response_body
# Explicitly list attributes allowed to be updated.
# Since updating `domain_id` is since very long time marked deprecated do not
# even include it in the schema
project_update_request_body: dict[str, Any] = {
"type": "object",
"properties": {
"project": {
"type": "object",
"properties": {
'description': validation.nullable(
old_parameter_types.description
),
'enabled': parameter_types.boolean,
'name': _name_properties,
'tags': _project_tags_list_properties,
},
}
},
"additionalProperties": False,
}
project_update_response_body: dict[str, Any] = project_get_response_body
tags_response_body: dict[str, Any] = response_types.tags
tags_update_request_body: dict[str, Any] = {
"type": "object",
"properties": {
"tags": {"type": "array", "items": _project_tag_name_properties}
},
"additionalProperties": False,
}

View File

@ -82,7 +82,12 @@ entity_update = {
_VALID_ENABLED_FORMATS = [True, False] _VALID_ENABLED_FORMATS = [True, False]
_INVALID_ENABLED_FORMATS = ['some string', 1, 0, 'True', 'False'] # NOTE(gtema): During impliementing JsonSchema validation some
# normalization has been done and it was noticed that value 'True' for boolean
# is explicitly expected in i.e. is_domain while other test verifies it is
# rejected. Therefore `True` and `False` values are removed from invalid values
# to be more conforming to other OpenStack services.
_INVALID_ENABLED_FORMATS = ['some string', 1, 0]
_INVALID_DESC_FORMATS = [False, 1, 2.0] _INVALID_DESC_FORMATS = [False, 1, 2.0]