From ee4aef7dd77e0fac694c746a69909752e060fb4e Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Thu, 22 May 2025 13:14:52 +0200 Subject: [PATCH] Separate user response and request schema There are more then only few attributes only present in the response and cannot be present in the request. The worst effect is that boolean (enabled) cannot be returned as all this amount of values supported on the input. This leads to pretty weird results when using produces openapi spec with the mock generators. Copy out user properties adopting them to the reality of responses. Change-Id: Ic4c81cc8f7b90adb2cb6cea19cf2b3c4bff2c00f Signed-off-by: Artem Goncharov --- keystone/identity/schema.py | 309 ++++++++++++++++++++++++------------ keystone/resource/schema.py | 20 +++ 2 files changed, 229 insertions(+), 100 deletions(-) diff --git a/keystone/identity/schema.py b/keystone/identity/schema.py index 81a28fb82f..dc7ed79f30 100644 --- a/keystone/identity/schema.py +++ b/keystone/identity/schema.py @@ -15,18 +15,18 @@ from typing import Any from keystone.api.validation import parameter_types from keystone.api.validation import response_types -from keystone.common import validation import keystone.conf from keystone.identity.backends import resource_options as ro +from keystone.resource import schema as resource_schema CONF = keystone.conf.CONF _identity_name: dict[str, Any] = { - 'type': 'string', - 'minLength': 1, - 'maxLength': 255, - 'pattern': r'[\S]+', + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": r"[\S]+", } # Schema for Identity v3 API @@ -47,10 +47,10 @@ user_index_request_query: dict[str, Any] = { "password_expires_at": { "type": "string", "description": ( - "Filter results based on which user passwords have " - "expired. The query should include an operator and " - "a timestamp with a colon (:) separating the two, for " - "example: `password_expires_at={operator}:{timestamp}`\n" + "Filter results based on which user passwords have expired. " + "The query should include an operator and a timestamp with a " + "colon (:) separating the two, for example: " + "`password_expires_at={operator}:{timestamp}`\n" "Valid operators are: lt, lte, gt, gte, eq, and neq\n" " - lt: expiration time lower than the timestamp\n" " - lte: expiration time lower than or equal to the timestamp\n" @@ -61,8 +61,8 @@ user_index_request_query: dict[str, Any] = { "Valid timestamps are of the form: `YYYY-MM-DDTHH:mm:ssZ`." "For example:" "`/v3/users?password_expires_at=lt:2016-12-08T22:02:00Z`\n" - "The example would return a list of users whose password " - "expired before the timestamp `(2016-12-08T22:02:00Z).`" + "The example would return a list of users whose password expired" + " before the timestamp `(2016-12-08T22:02:00Z).`" ), }, "protocol_id": { @@ -87,54 +87,141 @@ user_index_request_query: dict[str, Any] = { } _user_properties: dict[str, Any] = { - 'id': parameter_types.user_id, - 'default_project_id': validation.nullable(parameter_types.id_string), - 'description': validation.nullable(parameter_types.description), - 'domain_id': parameter_types.id_string, - 'enabled': parameter_types.boolean, - 'federated': { - 'type': 'array', - 'items': { - 'type': 'object', - 'properties': { - 'idp_id': {'type': 'string'}, - 'protocols': { - 'type': 'array', - 'items': { - 'type': 'object', - 'properties': { - 'protocol_id': {'type': 'string'}, - 'unique_id': {'type': 'string'}, + "default_project_id": { + "type": ["string", "null"], + "description": ( + "The ID of the default project for the user. A user’s default" + " project must not be a domain. Setting this attribute does not" + " grant any actual authorization on the project, and is merely" + " provided for convenience. Therefore, the referenced project does" + " not need to exist within the user domain. (Since v3.1) If the" + " user does not have authorization to their default project, the" + " default project is ignored at token creation. (Since v3.1)" + " Additionally, if your default project is not valid, a token is" + " issued without an explicit scope of authorization." + ), + }, + "description": { + "type": ["string", "null"], + "description": "The description of the user resource.", + }, + "domain_id": parameter_types.domain_id, + "enabled": parameter_types.boolean, + "federated": { + "description": ( + "List of federated objects associated with a user. Each object in" + " the list contains the idp_id and protocols. protocols is a list" + " of objects, each of which contains protocol_id and unique_id of" + " the protocol and user respectively." + ), + "type": "array", + "items": { + "type": "object", + "properties": { + "idp_id": {"type": "string"}, + "protocols": { + "type": "array", + "items": { + "type": "object", + "properties": { + "protocol_id": {"type": "string"}, + "unique_id": {"type": "string"}, }, - 'required': ['protocol_id', 'unique_id'], + "required": ["protocol_id", "unique_id"], }, - 'minItems': 1, + "minItems": 1, }, }, - 'required': ['idp_id', 'protocols'], + "required": ["idp_id", "protocols"], }, }, - 'links': response_types.links, - 'name': _identity_name, - 'password_expires_at': { - "type": ["string", "null"], - "format": "date-time", + "name": { "description": ( - "The date and time when the password expires. The time zone is UTC. " - "This is a response object attribute; not valid for requests. A " - "null value indicates that the password never expires." + "The user name. Must be unique within the owning domain." ), - "readOnly": True, + **_identity_name, }, - 'options': ro.USER_OPTIONS_REGISTRY.json_schema, + "password": { + "type": ["string", "null"], + "description": "The password for the user.", + }, + "options": ro.USER_OPTIONS_REGISTRY.json_schema, } user_schema: dict[str, Any] = { "type": "object", - "properties": _user_properties, + "properties": { + "id": {"type": "string", "description": "The user ID."}, + "default_project_id": { + "type": ["string", "null"], + "description": "The ID of the default project for the user.", + }, + "description": { + "type": ["string", "null"], + "description": "The user description", + }, + "domain_id": resource_schema.domain_id, + "enabled": { + "type": "boolean", + "description": ( + "If the user is enabled, this value is true. If the user is" + " disabled, this value is false." + ), + }, + "federated": { + "description": ( + "List of federated objects associated with a user. Each object" + " in the list contains the idp_id and protocols. protocols is" + " a list of objects, each of which contains protocol_id and" + " unique_id of the protocol and user respectively." + ), + "type": "array", + "items": { + "type": "object", + "properties": { + "idp_id": { + "type": "string", + "description": ( + "The Identity Provider ID of the federated user" + ), + }, + "protocols": { + "type": "array", + "items": { + "type": "object", + "properties": { + "protocol_id": {"type": "string"}, + "unique_id": {"type": "string"}, + }, + "required": ["protocol_id", "unique_id"], + }, + "minItems": 1, + }, + }, + "required": ["idp_id", "protocols"], + }, + }, + "links": response_types.links, + "name": { + "type": "string", + "description": ( + "The user name. Must be unique within the owning domain." + ), + }, + "password_expires_at": { + "type": ["string", "null"], + "format": "date-time", + "description": ( + "The date and time when the password expires. The time zone is" + " UTC. A null value indicates that the password never expires." + ), + }, + "options": ro.USER_OPTIONS_REGISTRY.json_schema, + }, # NOTE(gtema) User resource supports additional attributes which are stored # in the `extra` DB field "additionalProperties": True, + "required": ["id", "domain_id", "enabled", "name"], } user_index_response_body: dict[str, Any] = { @@ -160,7 +247,7 @@ user_create_request: dict[str, Any] = { "user": { "type": "object", "properties": { - "password": {"type": ["string", "null"]}, + "domain_id": parameter_types.domain_id, **_user_properties, }, "required": ["name"], @@ -175,22 +262,18 @@ user_create_response_body: dict[str, Any] = user_get_response_body user_update_properties = copy.deepcopy(_user_properties) # It is not allowed anymore to update domain of the existing user -user_update_properties.pop("domain_id", None) user_update_request: dict[str, Any] = { - 'type': 'object', - 'properties': { - 'user': { - 'type': 'object', - 'properties': { - 'password': {'type': ['string', 'null']}, - **user_update_properties, - }, - 'minProperties': 1, - 'additionalProperties': True, + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": _user_properties, + "minProperties": 1, + "additionalProperties": True, } }, - 'required': ['user'], - 'additionalProperties': False, + "required": ["user"], + "additionalProperties": False, } user_update_response_body: dict[str, Any] = user_get_response_body @@ -213,18 +296,22 @@ group_index_request_query: dict[str, Any] = { "additionalProperties": True, } -_group_properties: dict[str, Any] = { - 'description': validation.nullable(parameter_types.description), - 'domain_id': parameter_types.id_string, - 'id': {"type": "string", "description": "The user ID.", "readOnly": True}, - 'name': _identity_name, -} - group_schema: dict[str, Any] = { "type": "object", - "properties": _group_properties, - # NOTE(gtema) Group resource supports additional attributes which are stored - # in the `extra` DB field + "properties": { + "description": { + "type": ["string", "null"], + "description": "The description of the user group resource.", + }, + "domain_id": resource_schema.domain_id, + "id": {"type": "string", "description": "The user ID."}, + "name": { + "type": "string", + "description": "The name of tje user group.", + }, + }, + # NOTE(gtema) Group resource supports additional attributes which are + # stored in the `extra` DB field "additionalProperties": True, } @@ -245,19 +332,33 @@ group_index_response_body: dict[str, Any] = { group_get_response_body: dict[str, Any] = { "type": "object", - "properties": {"group": group_schema}, - "required": ["group"], - "additionalProperties": False, + "properties": { + "group": group_schema, + "required": ["group"], + "additionalProperties": False, + }, } group_create_request_body: dict[str, Any] = { - 'type': 'object', - 'properties': { - 'group': { - 'type': 'object', - 'properties': _group_properties, - 'required': ['name'], - 'additionalProperties': True, + "type": "object", + "properties": { + "group": { + "type": "object", + "properties": { + "domain_id": parameter_types.domain_id, + "description": { + "type": ["string", "null"], + "description": ( + "The description of the user group resource." + ), + }, + "name": { + "description": "The name of the user group.", + **_identity_name, + }, + }, + "required": ["name"], + "additionalProperties": True, } }, "required": ["group"], @@ -266,43 +367,51 @@ group_create_request_body: dict[str, Any] = { group_create_response_body = group_get_response_body -group_update_properties = copy.deepcopy(_group_properties) -# It is not allowed anymore to update domain of the existing group -group_update_properties.pop("domain_id", None) -group_update_request_body: dict[str, Any] = { - 'type': 'object', - 'properties': { - 'group': { - 'type': 'object', - 'properties': group_update_properties, - 'minProperties': 1, - 'additionalProperties': True, +group_update_request_body = { + "type": "object", + "properties": { + "group": { + "type": "object", + "properties": { + "description": { + "type": ["string", "null"], + "description": ( + "The new description of the user group resource." + ), + }, + "name": { + "description": "The new name of the user group.", + **_identity_name, + }, + }, + "minProperties": 1, + "additionalProperties": True, } }, "required": ["group"], - 'additionalProperties': False, + "additionalProperties": False, } group_update_response_body = group_get_response_body _password_change_properties = { - 'original_password': {'type': 'string'}, - 'password': {'type': 'string'}, + "original_password": {"type": "string"}, + "password": {"type": "string"}, } -if getattr(CONF, 'strict_password_check', None): - _password_change_properties['password']['maxLength'] = ( +if getattr(CONF, "strict_password_check", None): + _password_change_properties["password"]["maxLength"] = ( CONF.identity.max_password_length ) -if getattr(CONF, 'security_compliance', None): - if getattr(CONF.security_compliance, 'password_regex', None): - _password_change_properties['password']['pattern'] = ( +if getattr(CONF, "security_compliance", None): + if getattr(CONF.security_compliance, "password_regex", None): + _password_change_properties["password"]["pattern"] = ( CONF.security_compliance.password_regex ) password_change = { - 'type': 'object', - 'properties': _password_change_properties, - 'required': ['original_password', 'password'], - 'additionalProperties': False, + "type": "object", + "properties": _password_change_properties, + "required": ["original_password", "password"], + "additionalProperties": False, } diff --git a/keystone/resource/schema.py b/keystone/resource/schema.py index f19c4742c1..f6370432ce 100644 --- a/keystone/resource/schema.py +++ b/keystone/resource/schema.py @@ -19,6 +19,26 @@ from keystone.common import validation from keystone.common.validation import parameter_types as old_parameter_types from keystone.resource.backends import resource_options as ro +domain_id: dict[str, Any] = { + "type": "string", + "description": "The ID of the domain.", +} + +domain_name: dict[str, Any] = { + "type": "string", + "description": "The name of the domain.", +} + +project_id: dict[str, Any] = { + "type": "string", + "description": "The ID of the project.", +} + +default_project_id: dict[str, Any] = { + "type": ["string", "null"], + "description": "The ID of the project.", +} + _name_properties = { 'type': 'string', 'description': 'The resource name.',