diff --git a/keystone/api/projects.py b/keystone/api/projects.py index cd5d73cbc1..31b8767091 100644 --- a/keystone/api/projects.py +++ b/keystone/api/projects.py @@ -17,10 +17,11 @@ import http.client import flask +from keystone.api import validation from keystone.common import json_home from keystone.common import provider_api from keystone.common import rbac_enforcer -from keystone.common import validation +from keystone.common import validation as ks_validation import keystone.conf from keystone import exception from keystone.i18n import _ @@ -54,6 +55,7 @@ class ProjectResource(ks_flask.ResourceBase): ) 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_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/HEAD /v3/projects/{project_id} @@ -113,7 +117,51 @@ class ProjectResource(ks_flask.ResourceBase): self._expand_project_ref(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. GET/HEAD /v3/projects @@ -148,17 +196,8 @@ class ProjectResource(ks_flask.ResourceBase): filtered_refs = refs return self.wrap_collection(filtered_refs, hints=hints) - def get(self, project_id=None): - """Get project or list projects. - - 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() - + @validation.request_body_schema(schema.project_create_request_body) + @validation.response_body_schema(schema.project_create_response_body) def post(self): """Create project. @@ -169,7 +208,6 @@ class ProjectResource(ks_flask.ResourceBase): ENFORCER.enforce_call( action='identity:create_project', target_attr=target ) - validation.lazy_validate(schema.project_create, project) project = self._assign_unique_id(project) if not project.get('is_domain'): project = self._normalize_domain_id(project) @@ -187,37 +225,6 @@ class ProjectResource(ks_flask.ResourceBase): raise exception.ValidationError(e) 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): collection_key = 'projects' @@ -237,6 +244,8 @@ class _ProjectTagResourceBase(ks_flask.ResourceBase): class ProjectTagsResource(_ProjectTagResourceBase): + @validation.request_body_schema(None) + @validation.response_body_schema(schema.tags_response_body) def get(self, project_id): """List tags associated with a given project. @@ -249,6 +258,8 @@ class ProjectTagsResource(_ProjectTagResourceBase): ref = PROVIDERS.resource_api.list_project_tags(project_id) 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): """Update all tags associated with a given project. @@ -259,12 +270,14 @@ class ProjectTagsResource(_ProjectTagResourceBase): build_target=_build_project_target_enforcement, ) 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( project_id, tags, 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 all tags associated with a given project. @@ -279,6 +292,8 @@ class ProjectTagsResource(_ProjectTagResourceBase): class ProjectTagResource(_ProjectTagResourceBase): + @validation.request_body_schema(None) + @validation.response_body_schema(None) def get(self, project_id, value): """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) return None, http.client.NO_CONTENT + @validation.request_body_schema(None) + @validation.response_body_schema(None) def put(self, project_id, value): """Add a single tag to a project. @@ -300,11 +317,11 @@ class ProjectTagResource(_ProjectTagResourceBase): action='identity:create_project_tag', 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 tags = PROVIDERS.resource_api.list_project_tags(project_id) 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( project_id, value, initiator=self.audit_initiator ) @@ -313,6 +330,8 @@ class ProjectTagResource(_ProjectTagResourceBase): response.headers['Location'] = url return response + @validation.request_body_schema(None) + @validation.response_body_schema(None) def delete(self, project_id, value): """Delete a single tag from a project. @@ -566,8 +585,21 @@ class ProjectGroupListGrantResource(_ProjectGrantResourceBase): class ProjectAPI(ks_flask.APIBase): _name = 'projects' _import_name = __name__ - resources = [ProjectResource] 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/', + resource_kwargs={}, + rel="project", + path_vars={'project_id': json_home.Parameters.PROJECT_ID}, + ), ks_flask.construct_resource_map( resource=ProjectTagsResource, url='/projects//tags', diff --git a/keystone/api/validation/parameter_types.py b/keystone/api/validation/parameter_types.py index df0c270979..5da1c6448a 100644 --- a/keystone/api/validation/parameter_types.py +++ b/keystone/api/validation/parameter_types.py @@ -13,6 +13,8 @@ """Common parameter types for validating API requests.""" from typing import Any +empty: dict[str, Any] = {"type": "null"} + name: dict[str, Any] = { "type": "string", "minLength": 1, diff --git a/keystone/api/validation/response_types.py b/keystone/api/validation/response_types.py index 243a3634c6..6732f8895d 100644 --- a/keystone/api/validation/response_types.py +++ b/keystone/api/validation/response_types.py @@ -39,3 +39,12 @@ truncated: dict[str, Any] = { "response limit" ), } + +tags: dict[str, Any] = { + "type": "object", + "properties": { + "tags": {"type": "array", "items": {"type": "string"}}, + "links": links, + }, + "additionalProperties": False, +} diff --git a/keystone/resource/schema.py b/keystone/resource/schema.py index 687d75f027..b3480dd6aa 100644 --- a/keystone/resource/schema.py +++ b/keystone/resource/schema.py @@ -9,9 +9,12 @@ # 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 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.validation import parameter_types +from keystone.common.validation import parameter_types as old_parameter_types from keystone.resource.backends import resource_options as ro _name_properties = { @@ -41,12 +44,12 @@ _project_tags_list_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. - 'domain_id': validation.nullable(parameter_types.id_string), + 'domain_id': validation.nullable(old_parameter_types.id_string), 'enabled': 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, 'tags': _project_tags_list_properties, 'options': ro.PROJECT_OPTIONS_REGISTRY.json_schema, @@ -77,7 +80,7 @@ project_update = { } _domain_properties = { - 'description': validation.nullable(parameter_types.description), + 'description': validation.nullable(old_parameter_types.description), 'enabled': parameter_types.boolean, 'name': _name_properties, 'tags': project_tags_update, @@ -99,3 +102,91 @@ domain_update = { 'minProperties': 1, '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, +} diff --git a/keystone/tests/unit/test_validation.py b/keystone/tests/unit/test_validation.py index a735f2864a..6e9d0fb7b8 100644 --- a/keystone/tests/unit/test_validation.py +++ b/keystone/tests/unit/test_validation.py @@ -82,7 +82,12 @@ entity_update = { _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]