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 <artem.goncharov@gmail.com>
This commit is contained in:
Artem Goncharov committed 2025-06-10 12:16:31 +02:00
1 parent 1d67a0ae78
commit ee4aef7dd7
2 files changed
+229 -100

No files matched your search

+209 -100
View File
@@ -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,
}
+20
View File
@@ -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.',