cognito-idp standard attributes and pool schema validation (#4493)

This commit is contained in:
Łukasz 2021-10-29 13:25:52 +02:00 committed by GitHub
parent 0739537679
commit 07e8ba48ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 694 additions and 0 deletions

View File

@ -39,6 +39,280 @@ class UserStatus(str, enum.Enum):
RESET_REQUIRED = "RESET_REQUIRED"
class CognitoIdpUserPoolAttribute(BaseModel):
STANDARD_SCHEMA = {
"sub": {
"AttributeDataType": "String",
"Mutable": False,
"Required": True,
"StringAttributeConstraints": {"MinLength": "1", "MaxLength": "2048"},
},
"name": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"given_name": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"family_name": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"middle_name": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"nickname": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"preferred_username": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"profile": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"picture": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"website": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"email": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"email_verified": {
"AttributeDataType": "Boolean",
"Mutable": True,
"Required": False,
},
"gender": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"birthdate": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "10", "MaxLength": "10"},
},
"zoneinfo": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"locale": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"phone_number": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"phone_number_verified": {
"AttributeDataType": "Boolean",
"Mutable": True,
"Required": False,
},
"address": {
"AttributeDataType": "String",
"Mutable": True,
"Required": False,
"StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
},
"updated_at": {
"AttributeDataType": "Number",
"Mutable": True,
"Required": False,
"NumberAttributeConstraints": {"MinValue": "0"},
},
}
ATTRIBUTE_DATE_TYPES = {"Boolean", "DateTime", "String", "Number"}
def __init__(self, name, schema):
self.name = name
self.custom = self.name not in CognitoIdpUserPoolAttribute.STANDARD_SCHEMA
attribute_data_type = schema.get("AttributeDataType", None)
if (
attribute_data_type
and attribute_data_type
not in CognitoIdpUserPoolAttribute.ATTRIBUTE_DATE_TYPES
):
raise InvalidParameterException(
f"Validation error detected: Value '{attribute_data_type}' failed to satisfy constraint: Member must satisfy enum value set: [Boolean, Number, String, DateTime]"
)
if self.custom:
self._init_custom(schema)
else:
self._init_standard(schema)
def _init_custom(self, schema):
self.name = "custom:" + self.name
attribute_data_type = schema.get("AttributeDataType", None)
if not attribute_data_type:
raise InvalidParameterException(
"Invalid AttributeDataType input, consider using the provided AttributeDataType enum."
)
self.data_type = attribute_data_type
self.developer_only = schema.get("DeveloperOnlyAttribute", False)
if self.developer_only:
self.name = "dev:" + self.name
self.mutable = schema.get("Mutable", True)
if schema.get("Required", False):
raise InvalidParameterException(
"Required custom attributes are not supported currently."
)
self.required = False
self._init_constraints(schema, None)
def _init_standard(self, schema):
attribute_data_type = schema.get("AttributeDataType", None)
default_attribute_data_type = CognitoIdpUserPoolAttribute.STANDARD_SCHEMA[
self.name
]["AttributeDataType"]
if attribute_data_type and attribute_data_type != default_attribute_data_type:
raise InvalidParameterException(
f"You can not change AttributeDataType or set developerOnlyAttribute for standard schema attribute {self.name}"
)
self.data_type = default_attribute_data_type
if schema.get("DeveloperOnlyAttribute", False):
raise InvalidParameterException(
f"You can not change AttributeDataType or set developerOnlyAttribute for standard schema attribute {self.name}"
)
else:
self.developer_only = False
self.mutable = schema.get(
"Mutable",
CognitoIdpUserPoolAttribute.STANDARD_SCHEMA[self.name]["Mutable"],
)
self.required = schema.get(
"Required",
CognitoIdpUserPoolAttribute.STANDARD_SCHEMA[self.name]["Required"],
)
constraints_key = None
if self.data_type == "Number":
constraints_key = "NumberAttributeConstraints"
elif self.data_type == "String":
constraints_key = "StringAttributeConstraints"
default_constraints = (
None
if not constraints_key
else CognitoIdpUserPoolAttribute.STANDARD_SCHEMA[self.name][constraints_key]
)
self._init_constraints(schema, default_constraints)
def _init_constraints(self, schema, default_constraints):
def numeric_limit(num, constraint_type):
if not num:
return
parsed = None
try:
parsed = int(num)
except ValueError:
pass
if parsed is None or parsed < 0:
raise InvalidParameterException(
f"Invalid {constraint_type} for schema attribute {self.name}"
)
return parsed
self.string_constraints = None
self.number_constraints = None
if "AttributeDataType" in schema:
# Quirk - schema is set/validated only if AttributeDataType is specified
if self.data_type == "String":
string_constraints = schema.get(
"StringAttributeConstraints", default_constraints
)
if not string_constraints:
return
min_len = numeric_limit(
string_constraints.get("MinLength", None),
"StringAttributeConstraints",
)
max_len = numeric_limit(
string_constraints.get("MaxLength", None),
"StringAttributeConstraints",
)
if (min_len and min_len > 2048) or (max_len and max_len > 2048):
raise InvalidParameterException(
f"user.{self.name}: String attributes cannot have a length of more than 2048"
)
if min_len and max_len and min_len > max_len:
raise InvalidParameterException(
f"user.{self.name}: Max length cannot be less than min length."
)
self.string_constraints = string_constraints
elif self.data_type == "Number":
number_constraints = schema.get(
"NumberAttributeConstraints", default_constraints
)
if not number_constraints:
return
# No limits on either min or max value
min_val = numeric_limit(
number_constraints.get("MinValue", None),
"NumberAttributeConstraints",
)
max_val = numeric_limit(
number_constraints.get("MaxValue", None),
"NumberAttributeConstraints",
)
if min_val and max_val and min_val > max_val:
raise InvalidParameterException(
f"user.{self.name}: Max value cannot be less than min value."
)
self.number_constraints = number_constraints
def to_json(self):
return {
"Name": self.name,
"AttributeDataType": self.data_type,
"DeveloperOnlyAttribute": self.developer_only,
"Mutable": self.mutable,
"Required": self.required,
"NumberAttributeConstraints": self.number_constraints,
"StringAttributeConstraints": self.string_constraints,
}
class CognitoIdpUserPool(BaseModel):
def __init__(self, region, name, extended_config):
self.region = region
@ -56,6 +330,21 @@ class CognitoIdpUserPool(BaseModel):
self.sms_mfa_config = None
self.token_mfa_config = None
self.schema_attributes = {
schema["Name"]: CognitoIdpUserPoolAttribute(schema["Name"], schema)
for schema in extended_config.pop("Schema", {})
}
for (
standard_attribute_name,
standard_attribute_schema,
) in CognitoIdpUserPoolAttribute.STANDARD_SCHEMA.items():
if standard_attribute_name not in self.schema_attributes:
self.schema_attributes[
standard_attribute_name
] = CognitoIdpUserPoolAttribute(
standard_attribute_name, standard_attribute_schema
)
self.clients = OrderedDict()
self.identity_providers = OrderedDict()
self.groups = OrderedDict()
@ -99,6 +388,13 @@ class CognitoIdpUserPool(BaseModel):
user_pool_json = self._base_json()
if extended:
user_pool_json.update(self.extended_config)
user_pool_json.update(
{
"SchemaAttributes": [
att.to_json() for att in self.schema_attributes.values()
]
}
)
else:
user_pool_json["LambdaConfig"] = (
self.extended_config.get("LambdaConfig") or {}

View File

@ -43,6 +43,404 @@ def test_create_user_pool():
result["UserPool"]["LambdaConfig"]["PreSignUp"].should.equal(value)
@mock_cognitoidp
def test_create_user_pool_should_have_all_default_attributes_in_schema():
conn = boto3.client("cognito-idp", "us-west-2")
name = str(uuid.uuid4())
result = conn.create_user_pool(PoolName=name)
result_schema = result["UserPool"]["SchemaAttributes"]
result_schema = {s["Name"]: s for s in result_schema}
described_schema = conn.describe_user_pool(UserPoolId=result["UserPool"]["Id"])[
"UserPool"
]["SchemaAttributes"]
described_schema = {s["Name"]: s for s in described_schema}
for schema in result_schema, described_schema:
for (
default_attr_name,
default_attr,
) in moto.cognitoidp.models.CognitoIdpUserPoolAttribute.STANDARD_SCHEMA.items():
attribute = schema[default_attr_name]
attribute["Required"].should.equal(default_attr["Required"])
attribute["AttributeDataType"].should.equal(
default_attr["AttributeDataType"]
)
attribute["Mutable"].should.equal(default_attr["Mutable"])
attribute.get("StringAttributeConstraints", None).should.equal(
default_attr.get("StringAttributeConstraints", None)
)
attribute.get("NumberAttributeConstraints", None).should.equal(
default_attr.get("NumberAttributeConstraints", None)
)
attribute["DeveloperOnlyAttribute"].should.be.false
@mock_cognitoidp
def test_create_user_pool_unknown_attribute_data_type():
conn = boto3.client("cognito-idp", "us-west-2")
name = str(uuid.uuid4())
attribute_data_type = "Banana"
with pytest.raises(ClientError) as ex:
conn.create_user_pool(
PoolName=name,
Schema=[{"Name": "custom", "AttributeDataType": attribute_data_type,},],
)
ex.value.response["Error"]["Code"].should.equal("InvalidParameterException")
ex.value.response["Error"]["Message"].should.equal(
f"Validation error detected: Value '{attribute_data_type}' failed to satisfy constraint: Member must satisfy enum value set: [Boolean, Number, String, DateTime]"
)
ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
@mock_cognitoidp
def test_create_user_pool_custom_attribute_without_data_type():
conn = boto3.client("cognito-idp", "us-west-2")
with pytest.raises(ClientError) as ex:
conn.create_user_pool(PoolName=str(uuid.uuid4()), Schema=[{"Name": "custom",},])
ex.value.response["Error"]["Code"].should.equal("InvalidParameterException")
ex.value.response["Error"]["Message"].should.equal(
"Invalid AttributeDataType input, consider using the provided AttributeDataType enum."
)
ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
@mock_cognitoidp
def test_create_user_pool_custom_attribute_defaults():
conn = boto3.client("cognito-idp", "us-west-2")
res = conn.create_user_pool(
PoolName=str(uuid.uuid4()),
Schema=[
{"Name": "string", "AttributeDataType": "String",},
{"Name": "number", "AttributeDataType": "Number",},
],
)
string_attribute = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:string"
)
string_attribute["DeveloperOnlyAttribute"].should.be.false
string_attribute["Mutable"].should.be.true
string_attribute.get("StringAttributeConstraints").should.be.none
number_attribute = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:number"
)
number_attribute["DeveloperOnlyAttribute"].should.be.false
number_attribute["Mutable"].should.be.true
number_attribute.get("NumberAttributeConstraints").should.be.none
@mock_cognitoidp
def test_create_user_pool_custom_attribute_developer_only():
conn = boto3.client("cognito-idp", "us-west-2")
res = conn.create_user_pool(
PoolName=str(uuid.uuid4()),
Schema=[
{
"Name": "banana",
"AttributeDataType": "String",
"DeveloperOnlyAttribute": True,
},
],
)
# Note that this time we are looking for 'dev:xyz' attribute
attribute = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "dev:custom:banana"
)
attribute["DeveloperOnlyAttribute"].should.be.true
@mock_cognitoidp
def test_create_user_pool_custom_attribute_required():
conn = boto3.client("cognito-idp", "us-west-2")
with pytest.raises(ClientError) as ex:
conn.create_user_pool(
PoolName=str(uuid.uuid4()),
Schema=[
{"Name": "banana", "AttributeDataType": "String", "Required": True},
],
)
ex.value.response["Error"]["Code"].should.equal("InvalidParameterException")
ex.value.response["Error"]["Message"].should.equal(
"Required custom attributes are not supported currently."
)
ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
@mock_cognitoidp
@pytest.mark.parametrize(
"attribute",
[
{"Name": "email", "AttributeDataType": "Number"},
{"Name": "email", "DeveloperOnlyAttribute": True},
],
ids=["standard_attribute", "developer_only"],
)
def test_create_user_pool_standard_attribute_with_changed_data_type_or_developer_only(
attribute,
):
conn = boto3.client("cognito-idp", "us-west-2")
with pytest.raises(ClientError) as ex:
conn.create_user_pool(PoolName=str(uuid.uuid4()), Schema=[attribute])
ex.value.response["Error"]["Code"].should.equal("InvalidParameterException")
ex.value.response["Error"]["Message"].should.equal(
f"You can not change AttributeDataType or set developerOnlyAttribute for standard schema attribute {attribute['Name']}"
)
ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
@mock_cognitoidp
def test_create_user_pool_attribute_with_schema():
conn = boto3.client("cognito-idp", "us-west-2")
res = conn.create_user_pool(
PoolName=str(uuid.uuid4()),
Schema=[
{
"Name": "string",
"AttributeDataType": "String",
"NumberAttributeConstraints": {"MinValue": "10", "MaxValue": "20"},
"StringAttributeConstraints": {"MinLength": "10", "MaxLength": "20"},
},
{
"Name": "number",
"AttributeDataType": "Number",
"NumberAttributeConstraints": {"MinValue": "10", "MaxValue": "20"},
"StringAttributeConstraints": {"MinLength": "10", "MaxLength": "20"},
},
{
"Name": "boolean",
"AttributeDataType": "Boolean",
"NumberAttributeConstraints": {"MinValue": "10", "MaxValue": "20"},
"StringAttributeConstraints": {"MinLength": "10", "MaxLength": "20"},
},
],
)
string_attribute = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:string"
)
string_attribute["StringAttributeConstraints"].should.equal(
{"MinLength": "10", "MaxLength": "20"}
)
string_attribute.get("NumberAttributeConstraints").should.be.none
number_attribute = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:number"
)
number_attribute["NumberAttributeConstraints"].should.equal(
{"MinValue": "10", "MaxValue": "20"}
)
number_attribute.get("StringAttributeConstraints").should.be.none
boolean_attribute = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:boolean"
)
boolean_attribute.get("NumberAttributeConstraints").should.be.none
boolean_attribute.get("StringAttributeConstraints").should.be.none
@mock_cognitoidp
def test_create_user_pool_attribute_partial_schema():
conn = boto3.client("cognito-idp", "us-west-2")
res = conn.create_user_pool(
PoolName=str(uuid.uuid4()),
Schema=[
{
"Name": "string_no_min",
"AttributeDataType": "String",
"StringAttributeConstraints": {"MaxLength": "10"},
},
{
"Name": "string_no_max",
"AttributeDataType": "String",
"StringAttributeConstraints": {"MinLength": "10"},
},
{
"Name": "number_no_min",
"AttributeDataType": "Number",
"NumberAttributeConstraints": {"MaxValue": "10"},
},
{
"Name": "number_no_max",
"AttributeDataType": "Number",
"NumberAttributeConstraints": {"MinValue": "10"},
},
],
)
string_no_min = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:string_no_min"
)
string_no_max = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:string_no_max"
)
number_no_min = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:number_no_min"
)
number_no_max = next(
attr
for attr in res["UserPool"]["SchemaAttributes"]
if attr["Name"] == "custom:number_no_max"
)
string_no_min["StringAttributeConstraints"]["MaxLength"].should.equal("10")
string_no_min["StringAttributeConstraints"].get("MinLength", None).should.be.none
string_no_max["StringAttributeConstraints"]["MinLength"].should.equal("10")
string_no_max["StringAttributeConstraints"].get("MaxLength", None).should.be.none
number_no_min["NumberAttributeConstraints"]["MaxValue"].should.equal("10")
number_no_min["NumberAttributeConstraints"].get("MinValue", None).should.be.none
number_no_max["NumberAttributeConstraints"]["MinValue"].should.equal("10")
number_no_max["NumberAttributeConstraints"].get("MaxValue", None).should.be.none
@mock_cognitoidp
@pytest.mark.parametrize(
("constraint_type", "attribute"),
[
(
"StringAttributeConstraints",
{
"Name": "email",
"AttributeDataType": "String",
"StringAttributeConstraints": {"MinLength": "invalid_value"},
},
),
(
"StringAttributeConstraints",
{
"Name": "email",
"AttributeDataType": "String",
"StringAttributeConstraints": {"MaxLength": "invalid_value"},
},
),
(
"NumberAttributeConstraints",
{
"Name": "updated_at",
"AttributeDataType": "Number",
"NumberAttributeConstraints": {"MaxValue": "invalid_value"},
},
),
(
"NumberAttributeConstraints",
{
"Name": "updated_at",
"AttributeDataType": "Number",
"NumberAttributeConstraints": {"MinValue": "invalid_value"},
},
),
],
ids=[
"invalid_min_length",
"invalid_max_length",
"invalid_max_value",
"invalid_min_value",
],
)
def test_create_user_pool_invalid_schema_values(constraint_type, attribute):
conn = boto3.client("cognito-idp", "us-west-2")
with pytest.raises(ClientError) as ex:
conn.create_user_pool(PoolName=str(uuid.uuid4()), Schema=[attribute])
ex.value.response["Error"]["Code"].should.equal("InvalidParameterException")
ex.value.response["Error"]["Message"].should.equal(
f"Invalid {constraint_type} for schema attribute {attribute['Name']}"
)
ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
@mock_cognitoidp
@pytest.mark.parametrize(
"attribute",
[
{
"Name": "email",
"AttributeDataType": "String",
"StringAttributeConstraints": {"MinLength": "2049"},
},
{
"Name": "email",
"AttributeDataType": "String",
"StringAttributeConstraints": {"MaxLength": "2049"},
},
],
ids=["invalid_min_length", "invalid_max_length"],
)
def test_create_user_pool_string_schema_max_length_over_2048(attribute):
conn = boto3.client("cognito-idp", "us-west-2")
with pytest.raises(ClientError) as ex:
conn.create_user_pool(PoolName=str(uuid.uuid4()), Schema=[attribute])
ex.value.response["Error"]["Code"].should.equal("InvalidParameterException")
ex.value.response["Error"]["Message"].should.equal(
f"user.{attribute['Name']}: String attributes cannot have a length of more than 2048"
)
ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
@mock_cognitoidp
def test_create_user_pool_string_schema_min_bigger_than_max():
conn = boto3.client("cognito-idp", "us-west-2")
with pytest.raises(ClientError) as ex:
conn.create_user_pool(
PoolName=str(uuid.uuid4()),
Schema=[
{
"Name": "email",
"AttributeDataType": "String",
"StringAttributeConstraints": {"MinLength": "2", "MaxLength": "1"},
}
],
)
ex.value.response["Error"]["Code"].should.equal("InvalidParameterException")
ex.value.response["Error"]["Message"].should.equal(
f"user.email: Max length cannot be less than min length."
)
ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
@mock_cognitoidp
def test_create_user_pool_number_schema_min_bigger_than_max():
conn = boto3.client("cognito-idp", "us-west-2")
with pytest.raises(ClientError) as ex:
conn.create_user_pool(
PoolName=str(uuid.uuid4()),
Schema=[
{
"Name": "updated_at",
"AttributeDataType": "Number",
"NumberAttributeConstraints": {"MinValue": "2", "MaxValue": "1"},
}
],
)
ex.value.response["Error"]["Code"].should.equal("InvalidParameterException")
ex.value.response["Error"]["Message"].should.equal(
f"user.updated_at: Max value cannot be less than min value."
)
ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
@mock_cognitoidp
def test_list_user_pools():
conn = boto3.client("cognito-idp", "us-west-2")