Add JSON schema and validation for role resource

Change-Id: Ie52dcda09f76bfd86b8e1a8b6d71dfd7078ecf22
This commit is contained in:
0weng 2024-12-10 13:06:37 -08:00
parent 7dc3199363
commit 0a690d47b2
3 changed files with 203 additions and 84 deletions

View File

@ -18,11 +18,11 @@ import flask
import flask_restful import flask_restful
from keystone.api._shared import implied_roles as shared from keystone.api._shared import implied_roles as shared
from keystone.api import validation
from keystone.assignment import schema from keystone.assignment import schema
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
import keystone.conf import keystone.conf
from keystone.server import flask as ks_flask from keystone.server import flask as ks_flask
@ -31,6 +31,69 @@ ENFORCER = rbac_enforcer.RBACEnforcer
PROVIDERS = provider_api.ProviderAPIs PROVIDERS = provider_api.ProviderAPIs
class RolesResource(ks_flask.ResourceBase):
collection_key = 'roles'
member_key = 'role'
get_member_from_driver = PROVIDERS.deferred_provider_lookup(
api='role_api', method='get_role'
)
def _is_domain_role(self, role):
return bool(role.get('domain_id'))
@validation.request_query_schema(schema.roles_index_request_query)
@validation.response_body_schema(schema.roles_index_response_body)
def get(self):
"""List roles.
GET/HEAD /v3/roles
"""
filters = ['name', 'domain_id']
domain_filter = flask.request.args.get('domain_id')
if domain_filter:
ENFORCER.enforce_call(
action='identity:list_domain_roles', filters=filters
)
else:
ENFORCER.enforce_call(
action='identity:list_roles', filters=filters
)
hints = self.build_driver_hints(filters)
if not domain_filter:
# NOTE(jamielennox): To handle the default case of not domain_id
# defined the role_assignment backend does some hackery to
# distinguish between global and domain scoped roles. This backend
# behaviour relies upon a value of domain_id being set (not just
# defaulting to None). Manually set the filter if its not
# provided.
hints.add_filter('domain_id', None)
refs = PROVIDERS.role_api.list_roles(hints=hints)
return self.wrap_collection(refs, hints=hints)
@validation.request_body_schema(schema.role_create_request_body)
@validation.response_body_schema(schema.role_show_response_body)
def post(self):
"""Create role.
POST /v3/roles
"""
role = self.request_body_json.get('role', {})
if self._is_domain_role(role):
target = {'role': role}
ENFORCER.enforce_call(
action='identity:create_domain_role', target_attr=target
)
else:
ENFORCER.enforce_call(action='identity:create_role')
role = self._assign_unique_id(role)
role = self._normalize_dict(role)
ref = PROVIDERS.role_api.create_role(
role['id'], role, initiator=self.audit_initiator
)
return self.wrap_member(ref), http.client.CREATED
class RoleResource(ks_flask.ResourceBase): class RoleResource(ks_flask.ResourceBase):
collection_key = 'roles' collection_key = 'roles'
member_key = 'role' member_key = 'role'
@ -41,17 +104,13 @@ class RoleResource(ks_flask.ResourceBase):
def _is_domain_role(self, role): def _is_domain_role(self, role):
return bool(role.get('domain_id')) return bool(role.get('domain_id'))
def get(self, role_id=None): @validation.request_body_schema(None)
"""Get role or list roles. @validation.response_body_schema(schema.role_show_response_body)
def get(self, role_id):
"""Get role.
GET/HEAD /v3/roles
GET/HEAD /v3/roles/{role_id} GET/HEAD /v3/roles/{role_id}
""" """
if role_id is not None:
return self._get_role(role_id)
return self._list_roles()
def _get_role(self, role_id):
err = None err = None
role = {} role = {}
try: try:
@ -80,51 +139,8 @@ class RoleResource(ks_flask.ResourceBase):
) )
return self.wrap_member(role) return self.wrap_member(role)
def _list_roles(self): @validation.request_body_schema(schema.role_update_request_body)
filters = ['name', 'domain_id'] @validation.response_body_schema(schema.role_show_response_body)
domain_filter = flask.request.args.get('domain_id')
if domain_filter:
ENFORCER.enforce_call(
action='identity:list_domain_roles', filters=filters
)
else:
ENFORCER.enforce_call(
action='identity:list_roles', filters=filters
)
hints = self.build_driver_hints(filters)
if not domain_filter:
# NOTE(jamielennox): To handle the default case of not domain_id
# defined the role_assignment backend does some hackery to
# distinguish between global and domain scoped roles. This backend
# behaviour relies upon a value of domain_id being set (not just
# defaulting to None). Manually set the filter if its not
# provided.
hints.add_filter('domain_id', None)
refs = PROVIDERS.role_api.list_roles(hints=hints)
return self.wrap_collection(refs, hints=hints)
def post(self):
"""Create role.
POST /v3/roles
"""
role = self.request_body_json.get('role', {})
if self._is_domain_role(role):
target = {'role': role}
ENFORCER.enforce_call(
action='identity:create_domain_role', target_attr=target
)
else:
ENFORCER.enforce_call(action='identity:create_role')
validation.lazy_validate(schema.role_create, role)
role = self._assign_unique_id(role)
role = self._normalize_dict(role)
ref = PROVIDERS.role_api.create_role(
role['id'], role, initiator=self.audit_initiator
)
return self.wrap_member(ref), http.client.CREATED
def patch(self, role_id): def patch(self, role_id):
"""Update role. """Update role.
@ -151,13 +167,14 @@ class RoleResource(ks_flask.ResourceBase):
member_target=role, member_target=role,
) )
request_body_role = self.request_body_json.get('role', {}) request_body_role = self.request_body_json.get('role', {})
validation.lazy_validate(schema.role_update, request_body_role)
self._require_matching_id(request_body_role) self._require_matching_id(request_body_role)
ref = PROVIDERS.role_api.update_role( ref = PROVIDERS.role_api.update_role(
role_id, request_body_role, initiator=self.audit_initiator role_id, request_body_role, 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, role_id): def delete(self, role_id):
"""Delete role. """Delete role.
@ -299,8 +316,23 @@ class RoleImplicationResource(flask_restful.Resource):
class RoleAPI(ks_flask.APIBase): class RoleAPI(ks_flask.APIBase):
_name = 'roles' _name = 'roles'
_import_name = __name__ _import_name = __name__
resources = [RoleResource]
resource_mapping = [ resource_mapping = [
ks_flask.construct_resource_map(
resource=RolesResource,
url='/roles',
resource_kwargs={},
rel="roles",
path_vars=None,
),
ks_flask.construct_resource_map(
resource=RoleResource,
url='/roles/<string:role_id>',
resource_kwargs={},
rel="role",
path_vars={
'role_id': json_home.build_v3_parameter_relation("role_id")
},
),
ks_flask.construct_resource_map( ks_flask.construct_resource_map(
resource=RoleImplicationListResource, resource=RoleImplicationListResource,
url='/roles/<string:prior_role_id>/implies', url='/roles/<string:prior_role_id>/implies',

View File

@ -10,27 +10,113 @@
# 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.assignment.role_backends import resource_options as ro from keystone.assignment.role_backends import resource_options as ro
from keystone.common.validation import parameter_types from keystone.common import validation
# Schema for Identity v3 API # Schema for Identity v3 API
_role_properties = { _role_properties: dict[str, Any] = {
'name': parameter_types.name, "name": parameter_types.name,
'description': parameter_types.description, "description": validation.nullable(parameter_types.description),
'options': ro.ROLE_OPTIONS_REGISTRY.json_schema, "domain_id": validation.nullable(parameter_types.domain_id),
"options": ro.ROLE_OPTIONS_REGISTRY.json_schema,
}
# NOTE(0weng): Multiple response body examples in the docs are
# incorrectly missing the `options` field.
# Common schema of `Role` resource
role_schema: dict[str, Any] = {
"type": "object",
"description": "A role object.",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "The role ID.",
"readOnly": True,
},
"links": response_types.resource_links,
**_role_properties,
},
"additionalProperties": True,
} }
role_create = { # Response body of API operations returning a single role
'type': 'object', # `GET /roles/{role_id}`, `POST /roles`, and `PATCH /roles/{role_id}`
'properties': _role_properties, role_show_response_body: dict[str, Any] = {
'required': ['name'], "type": "object",
'additionalProperties': True, "properties": {"role": role_schema},
"additionalProperties": False,
} }
role_update = { # Query parameters of the `GET /roles` API operation
'type': 'object', # returning a list of roles
'properties': _role_properties, roles_index_request_query: dict[str, Any] = {
'minProperties': 1, "type": "object",
'additionalProperties': True, "properties": {
"name": parameter_types.name,
"domain_id": parameter_types.domain_id,
},
"additionalProperties": False,
}
# Response body of the `GET /roles` API operation
# returning a list of roles
roles_index_response_body: dict[str, Any] = {
"type": "object",
"properties": {
"links": response_types.links,
"roles": {
"type": "array",
"items": role_schema,
"description": "A list of role objects.",
},
"truncated": response_types.truncated,
},
"additionalProperties": False,
}
# Request body of the `POST /roles` API operation
role_create_request_body = {
"type": "object",
"properties": {
"role": {
"type": "object",
"properties": _role_properties,
"description": "A role object.",
"required": ["name"],
"additionalProperties": True,
}
},
"required": ["role"],
"additionalProperties": False,
}
# FIXME(0weng): There's no error if additional properties are added
# at the top level, e.g. POST/PATCH with this body:
# {"role": {"some_key":"some_value"}, "no_error": "no_error_here"}
# Is this intended, or should it be disallowed by the schema (as it is here)?
# 400 errors do occur if the "role" property is missing
# (the error message is that '{}' is not enough properties,
# so I imagine extra properties are removed)
# or no properties are provided.
# Request body of the `PATCH /roles/{role_id}` operation
role_update_request_body = {
"type": "object",
"properties": {
"role": {
"type": "object",
"properties": _role_properties,
"description": "A role object.",
"minProperties": 1,
"additionalProperties": True,
}
},
"required": ["role"],
"additionalProperties": False,
} }

View File

@ -883,19 +883,19 @@ class RoleValidationTestCase(unit.BaseTestCase):
self.role_name = 'My Role' self.role_name = 'My Role'
create = assignment_schema.role_create create = assignment_schema.role_create_request_body
update = assignment_schema.role_update update = assignment_schema.role_update_request_body
self.create_role_validator = validators.SchemaValidator(create) self.create_role_validator = validators.SchemaValidator(create)
self.update_role_validator = validators.SchemaValidator(update) self.update_role_validator = validators.SchemaValidator(update)
def test_validate_role_request(self): def test_validate_role_request(self):
"""Test we can successfully validate a create role request.""" """Test we can successfully validate a create role request."""
request_to_validate = {'name': self.role_name} request_to_validate = {"role": {'name': self.role_name}}
self.create_role_validator.validate(request_to_validate) self.create_role_validator.validate(request_to_validate)
def test_validate_role_create_without_name_raises_exception(self): def test_validate_role_create_without_name_raises_exception(self):
"""Test that we raise an exception when `name` isn't included.""" """Test that we raise an exception when `name` isn't included."""
request_to_validate = {'enabled': True} request_to_validate = {"role": {'enabled': True}}
self.assertRaises( self.assertRaises(
exception.SchemaValidationError, exception.SchemaValidationError,
self.create_role_validator.validate, self.create_role_validator.validate,
@ -905,7 +905,7 @@ class RoleValidationTestCase(unit.BaseTestCase):
def test_validate_role_create_fails_with_invalid_name(self): def test_validate_role_create_fails_with_invalid_name(self):
"""Exception when validating a create request with invalid `name`.""" """Exception when validating a create request with invalid `name`."""
for invalid_name in _INVALID_NAMES: for invalid_name in _INVALID_NAMES:
request_to_validate = {'name': invalid_name} request_to_validate = {"role": {'name': invalid_name}}
self.assertRaises( self.assertRaises(
exception.SchemaValidationError, exception.SchemaValidationError,
self.create_role_validator.validate, self.create_role_validator.validate,
@ -915,7 +915,7 @@ class RoleValidationTestCase(unit.BaseTestCase):
def test_validate_role_create_request_with_name_too_long_fails(self): def test_validate_role_create_request_with_name_too_long_fails(self):
"""Exception raised when creating a role with `name` too long.""" """Exception raised when creating a role with `name` too long."""
long_role_name = 'a' * 256 long_role_name = 'a' * 256
request_to_validate = {'name': long_role_name} request_to_validate = {"role": {'name': long_role_name}}
self.assertRaises( self.assertRaises(
exception.SchemaValidationError, exception.SchemaValidationError,
self.create_role_validator.validate, self.create_role_validator.validate,
@ -923,16 +923,17 @@ class RoleValidationTestCase(unit.BaseTestCase):
) )
def test_validate_role_request_with_valid_description(self): def test_validate_role_request_with_valid_description(self):
"""Test we can validate`description` in create role request.""" """Test we can validate `description` in create role request."""
request_to_validate = { request_to_validate = {
'name': self.role_name, "role": {'name': self.role_name, 'description': 'My Role'}
'description': 'My Role',
} }
self.create_role_validator.validate(request_to_validate) self.create_role_validator.validate(request_to_validate)
def test_validate_role_request_fails_with_invalid_description(self): def test_validate_role_request_fails_with_invalid_description(self):
"""Exception is raised when `description` as a non-string value.""" """Exception is raised when `description` as a non-string value."""
request_to_validate = {'name': self.role_name, 'description': False} request_to_validate = {
"role": {'name': self.role_name, 'description': False}
}
self.assertRaises( self.assertRaises(
exception.SchemaValidationError, exception.SchemaValidationError,
self.create_role_validator.validate, self.create_role_validator.validate,
@ -941,13 +942,13 @@ class RoleValidationTestCase(unit.BaseTestCase):
def test_validate_role_update_request(self): def test_validate_role_update_request(self):
"""Test that we validate a role update request.""" """Test that we validate a role update request."""
request_to_validate = {'name': 'My New Role'} request_to_validate = {"role": {'name': 'My New Role'}}
self.update_role_validator.validate(request_to_validate) self.update_role_validator.validate(request_to_validate)
def test_validate_role_update_fails_with_invalid_name(self): def test_validate_role_update_fails_with_invalid_name(self):
"""Exception when validating an update request with invalid `name`.""" """Exception when validating an update request with invalid `name`."""
for invalid_name in _INVALID_NAMES: for invalid_name in _INVALID_NAMES:
request_to_validate = {'name': invalid_name} request_to_validate = {"role": {'name': invalid_name}}
self.assertRaises( self.assertRaises(
exception.SchemaValidationError, exception.SchemaValidationError,
self.update_role_validator.validate, self.update_role_validator.validate,
@ -957,7 +958,7 @@ class RoleValidationTestCase(unit.BaseTestCase):
def test_validate_role_update_request_with_name_too_long_fails(self): def test_validate_role_update_request_with_name_too_long_fails(self):
"""Exception raised when updating a role with `name` too long.""" """Exception raised when updating a role with `name` too long."""
long_role_name = 'a' * 256 long_role_name = 'a' * 256
request_to_validate = {'name': long_role_name} request_to_validate = {"role": {'name': long_role_name}}
self.assertRaises( self.assertRaises(
exception.SchemaValidationError, exception.SchemaValidationError,
self.update_role_validator.validate, self.update_role_validator.validate,