From 3a73524f2a276d95039ab43cffd43afb731dd4cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sat, 16 Aug 2025 23:17:13 +0200 Subject: [PATCH 1/3] fix: resolve RFC 7643 inconsistency in ServiceProviderConfig authenticationSchemes schema RFC 7643 contains a documented inconsistency between the ServiceProviderConfig schema definition (section 8.7.2) and the example data (section 8.5): - Schema definition only specifies 4 sub-attributes for authenticationSchemes: name, description, specUri, documentationUri - Example data contains additional attributes: type, primary This inconsistency is acknowledged in RFC Errata ID 7921, where the schema definition is incomplete compared to the actual usage patterns shown in examples and implementations. --- ...schema-service_provider_configuration.json | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json index 18b11f8..f7c5d1f 100644 --- a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json +++ b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json @@ -177,6 +177,24 @@ "returned": "default", "mutability": "readOnly", "subAttributes": [ + { + "name": "type", + "type": "string", + "multiValued": false, + "description": "The authentication scheme.", + "required": true, + "canonicalValues": [ + "oauth", + "oauth2", + "oauthbearertoken", + "httpbasic", + "httpdigest" + ], + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, { "name": "name", "type": "string", @@ -226,6 +244,17 @@ "mutability": "readOnly", "returned": "default", "uniqueness": "none" + }, + { + "name": "primary", + "type": "boolean", + "multiValued": false, + "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute.", + "required": false, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" } ] } From bf3ead36159d1eec54911c351a2ad8f96c953f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sat, 16 Aug 2025 23:17:28 +0200 Subject: [PATCH 2/3] fix: ServiceProviderConfig documentation and typos --- scim2_models/resources/service_provider_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index 12f0c51..9911bdd 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -38,7 +38,7 @@ class Filter(ComplexAttribute): """A Boolean value specifying whether or not the operation is supported.""" max_results: Annotated[Optional[int], Mutability.read_only, Required.true] = None - """A Boolean value specifying whether or not the operation is supported.""" + """An integer value specifying the maximum number of resources returned in a response.""" class ChangePassword(ComplexAttribute): @@ -66,7 +66,7 @@ class Type(str, Enum): type: Annotated[Optional[Type], Mutability.read_only, Required.true] = Field( None, - examples=["oauth", "oauth2", "oauthbreakertoken", "httpbasic", "httpdigest"], + examples=["oauth", "oauth2", "oauthbearertoken", "httpbasic", "httpdigest"], ) """The authentication scheme.""" From 11533f59171b07bc87f765971d07a14af1ef44cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sat, 16 Aug 2025 23:19:50 +0200 Subject: [PATCH 3/3] fix: explicit ComplexAttribute sub-attributes Multiple ComplexAttribute do not inherit from MultiValuedComplexAttribute by default. --- doc/changelog.rst | 1 + scim2_models/attributes.py | 2 +- scim2_models/resources/group.py | 3 +- scim2_models/resources/resource.py | 6 +--- scim2_models/resources/schema.py | 15 +++----- scim2_models/resources/user.py | 55 +++++++++++++++++++++++------- tests/test_dynamic_resources.py | 23 ++++++------- tests/test_dynamic_schemas.py | 24 ------------- 8 files changed, 62 insertions(+), 67 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 0a8e09a..2869dd8 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -8,6 +8,7 @@ Fixed ^^^^^ - Attributes with ``None`` type are excluded from Schema generation. - Allow PATCH operations on resources and extensions root path. +- Multiple ComplexAttribute do not inherit from MultiValuedComplexAttribute by default. :issue:`72` :issue:`73` [0.4.2] - 2025-08-05 -------------------- diff --git a/scim2_models/attributes.py b/scim2_models/attributes.py index d32bbb0..9f70a1e 100644 --- a/scim2_models/attributes.py +++ b/scim2_models/attributes.py @@ -53,5 +53,5 @@ def is_complex_attribute(type_: type) -> bool: return ( get_origin(type_) != Reference and isclass(type_) - and issubclass(type_, (ComplexAttribute, MultiValuedComplexAttribute)) + and issubclass(type_, ComplexAttribute) ) diff --git a/scim2_models/resources/group.py b/scim2_models/resources/group.py index 2f6df73..639cd60 100644 --- a/scim2_models/resources/group.py +++ b/scim2_models/resources/group.py @@ -10,12 +10,11 @@ from ..annotations import Mutability from ..annotations import Required from ..attributes import ComplexAttribute -from ..attributes import MultiValuedComplexAttribute from ..reference import Reference from .resource import Resource -class GroupMember(MultiValuedComplexAttribute): +class GroupMember(ComplexAttribute): value: Annotated[Optional[str], Mutability.immutable] = None """Identifier of the member of this Group.""" diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index 2fa055a..8befe78 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -22,7 +22,6 @@ from ..annotations import Returned from ..annotations import Uniqueness from ..attributes import ComplexAttribute -from ..attributes import MultiValuedComplexAttribute from ..attributes import is_complex_attribute from ..base import BaseModel from ..context import Context @@ -438,10 +437,7 @@ def _model_attribute_to_scim_attribute( sub_attributes = ( [ _model_attribute_to_scim_attribute(root_type, sub_attribute_name) - for sub_attribute_name in _dedicated_attributes( - root_type, - [MultiValuedComplexAttribute], - ) + for sub_attribute_name in root_type.model_fields # type: ignore if ( attribute_name != "sub_attributes" or sub_attribute_name != "sub_attributes" diff --git a/scim2_models/resources/schema.py b/scim2_models/resources/schema.py index ff7357a..499a895 100644 --- a/scim2_models/resources/schema.py +++ b/scim2_models/resources/schema.py @@ -23,7 +23,6 @@ from ..annotations import Returned from ..annotations import Uniqueness from ..attributes import ComplexAttribute -from ..attributes import MultiValuedComplexAttribute from ..attributes import is_complex_attribute from ..base import BaseModel from ..constants import RESERVED_WORDS @@ -49,7 +48,6 @@ def _make_python_identifier(identifier: str) -> str: def _make_python_model( obj: Union["Schema", "Attribute"], base: type[T], - multiple: bool = False, ) -> type[T]: """Build a Python model from a Schema or an Attribute object.""" if isinstance(obj, Attribute): @@ -99,7 +97,6 @@ class Type(str, Enum): def _to_python( self, - multiple: bool = False, reference_types: Optional[list[str]] = None, ) -> type: if self.value == self.reference and reference_types is not None: @@ -119,9 +116,7 @@ def _to_python( self.integer: int, self.date_time: datetime, self.binary: Base64Bytes, - self.complex: MultiValuedComplexAttribute - if multiple - else ComplexAttribute, + self.complex: ComplexAttribute, } return attr_types[self.value] @@ -215,12 +210,10 @@ def _to_python(self) -> Optional[tuple[Any, Any]]: if not self.name or not self.type: return None - attr_type = self.type._to_python(bool(self.multi_valued), self.reference_types) + attr_type = self.type._to_python(self.reference_types) - if attr_type in (ComplexAttribute, MultiValuedComplexAttribute): - attr_type = _make_python_model( - obj=self, base=attr_type, multiple=bool(self.multi_valued) - ) + if attr_type == ComplexAttribute: + attr_type = _make_python_model(obj=self, base=attr_type) if self.multi_valued: attr_type = list[attr_type] # type: ignore diff --git a/scim2_models/resources/user.py b/scim2_models/resources/user.py index 91931da..deefb89 100644 --- a/scim2_models/resources/user.py +++ b/scim2_models/resources/user.py @@ -14,7 +14,6 @@ from ..annotations import Returned from ..annotations import Uniqueness from ..attributes import ComplexAttribute -from ..attributes import MultiValuedComplexAttribute from ..reference import ExternalReference from ..reference import Reference from ..utils import Base64Bytes @@ -48,7 +47,7 @@ class Name(ComplexAttribute): languages (e.g., 'III' given the full name 'Ms. Barbara J Jensen, III').""" -class Email(MultiValuedComplexAttribute): +class Email(ComplexAttribute): class Type(str, Enum): work = "work" home = "home" @@ -69,7 +68,7 @@ class Type(str, Enum): address.""" -class PhoneNumber(MultiValuedComplexAttribute): +class PhoneNumber(ComplexAttribute): class Type(str, Enum): work = "work" home = "home" @@ -96,7 +95,7 @@ class Type(str, Enum): number.""" -class Im(MultiValuedComplexAttribute): +class Im(ComplexAttribute): class Type(str, Enum): aim = "aim" gtalk = "gtalk" @@ -124,7 +123,7 @@ class Type(str, Enum): for this attribute, e.g., the preferred messenger or primary messenger.""" -class Photo(MultiValuedComplexAttribute): +class Photo(ComplexAttribute): class Type(str, Enum): photo = "photo" thumbnail = "thumbnail" @@ -144,7 +143,7 @@ class Type(str, Enum): for this attribute, e.g., the preferred photo or thumbnail.""" -class Address(MultiValuedComplexAttribute): +class Address(ComplexAttribute): class Type(str, Enum): work = "work" home = "home" @@ -181,11 +180,22 @@ class Type(str, Enum): for this attribute, e.g., the preferred photo or thumbnail.""" -class Entitlement(MultiValuedComplexAttribute): - pass +class Entitlement(ComplexAttribute): + value: Optional[str] = None + """The value of an entitlement.""" + + display: Optional[str] = None + """A human-readable name, primarily used for display purposes.""" + + type: Optional[str] = None + """A label indicating the attribute's function.""" + + primary: Optional[bool] = None + """A Boolean value indicating the 'primary' or preferred attribute value + for this attribute.""" -class GroupMembership(MultiValuedComplexAttribute): +class GroupMembership(ComplexAttribute): value: Annotated[Optional[str], Mutability.read_only] = None """The identifier of the User's group.""" @@ -206,14 +216,35 @@ class GroupMembership(MultiValuedComplexAttribute): 'indirect'.""" -class Role(MultiValuedComplexAttribute): - pass +class Role(ComplexAttribute): + value: Optional[str] = None + """The value of a role.""" + + display: Optional[str] = None + """A human-readable name, primarily used for display purposes.""" + type: Optional[str] = None + """A label indicating the attribute's function.""" -class X509Certificate(MultiValuedComplexAttribute): + primary: Optional[bool] = None + """A Boolean value indicating the 'primary' or preferred attribute value + for this attribute.""" + + +class X509Certificate(ComplexAttribute): value: Annotated[Optional[Base64Bytes], CaseExact.true] = None """The value of an X.509 certificate.""" + display: Optional[str] = None + """A human-readable name, primarily used for display purposes.""" + + type: Optional[str] = None + """A label indicating the attribute's function.""" + + primary: Optional[bool] = None + """A Boolean value indicating the 'primary' or preferred attribute value + for this attribute.""" + class User(Resource[AnyExtension]): schemas: Annotated[list[str], Required.true] = [ diff --git a/tests/test_dynamic_resources.py b/tests/test_dynamic_resources.py index 4ae0017..745c91c 100644 --- a/tests/test_dynamic_resources.py +++ b/tests/test_dynamic_resources.py @@ -8,7 +8,6 @@ from scim2_models.annotations import Returned from scim2_models.annotations import Uniqueness from scim2_models.attributes import ComplexAttribute -from scim2_models.attributes import MultiValuedComplexAttribute from scim2_models.reference import ExternalReference from scim2_models.reference import Reference from scim2_models.reference import URIReference @@ -408,7 +407,7 @@ def test_make_user_model_from_schema(load_sample): # emails Emails = User.get_field_root_type("emails") assert Emails == User.Emails - assert issubclass(Emails, MultiValuedComplexAttribute) + assert issubclass(Emails, ComplexAttribute) assert User.get_field_multiplicity("emails") assert ( User.model_fields["emails"].description @@ -476,7 +475,7 @@ def test_make_user_model_from_schema(load_sample): # phone_numbers PhoneNumbers = User.get_field_root_type("phone_numbers") assert PhoneNumbers == User.PhoneNumbers - assert issubclass(PhoneNumbers, MultiValuedComplexAttribute) + assert issubclass(PhoneNumbers, ComplexAttribute) assert User.get_field_multiplicity("phone_numbers") assert ( User.model_fields["phone_numbers"].description @@ -560,7 +559,7 @@ def test_make_user_model_from_schema(load_sample): # ims Ims = User.get_field_root_type("ims") assert Ims == User.Ims - assert issubclass(Ims, MultiValuedComplexAttribute) + assert issubclass(Ims, ComplexAttribute) assert User.get_field_multiplicity("ims") assert ( User.model_fields["ims"].description @@ -637,7 +636,7 @@ def test_make_user_model_from_schema(load_sample): # photos Photos = User.get_field_root_type("photos") assert Photos == User.Photos - assert issubclass(Photos, MultiValuedComplexAttribute) + assert issubclass(Photos, ComplexAttribute) assert User.get_field_multiplicity("photos") assert User.model_fields["photos"].description == "URLs of photos of the User." assert User.get_field_annotation("photos", Required) == Required.false @@ -699,7 +698,7 @@ def test_make_user_model_from_schema(load_sample): # addresses Addresses = User.get_field_root_type("addresses") assert Addresses == User.Addresses - assert issubclass(Addresses, MultiValuedComplexAttribute) + assert issubclass(Addresses, ComplexAttribute) assert User.get_field_multiplicity("addresses") assert ( User.model_fields["addresses"].description @@ -837,7 +836,7 @@ def test_make_user_model_from_schema(load_sample): # groups Groups = User.get_field_root_type("groups") assert Groups == User.Groups - assert issubclass(Groups, MultiValuedComplexAttribute) + assert issubclass(Groups, ComplexAttribute) assert User.get_field_multiplicity("groups") assert ( User.model_fields["groups"].description @@ -911,7 +910,7 @@ def test_make_user_model_from_schema(load_sample): # entitlements Entitlements = User.get_field_root_type("entitlements") assert Entitlements == User.Entitlements - assert issubclass(Entitlements, MultiValuedComplexAttribute) + assert issubclass(Entitlements, ComplexAttribute) assert User.get_field_multiplicity("entitlements") assert ( User.model_fields["entitlements"].description @@ -989,7 +988,7 @@ def test_make_user_model_from_schema(load_sample): # roles Roles = User.get_field_root_type("roles") assert Roles == User.Roles - assert issubclass(Roles, MultiValuedComplexAttribute) + assert issubclass(Roles, ComplexAttribute) assert User.get_field_multiplicity("roles") assert ( User.model_fields["roles"].description @@ -1053,7 +1052,7 @@ def test_make_user_model_from_schema(load_sample): # x_509_certificates X509Certificates = User.get_field_root_type("x_509_certificates") assert X509Certificates == User.X509Certificates - assert issubclass(X509Certificates, MultiValuedComplexAttribute) + assert issubclass(X509Certificates, ComplexAttribute) assert User.get_field_multiplicity("x_509_certificates") assert ( User.model_fields["x_509_certificates"].description @@ -2210,7 +2209,7 @@ def test_make_schema_model_from_schema(load_sample): # attributes Attributes = Schema_.get_field_root_type("attributes") assert Attributes == Schema_.Attributes - assert issubclass(Attributes, MultiValuedComplexAttribute) + assert issubclass(Attributes, ComplexAttribute) assert Schema_.get_field_multiplicity("attributes") assert ( Schema_.model_fields["attributes"].description @@ -2440,7 +2439,7 @@ def test_make_schema_model_from_schema(load_sample): # sub_attributes SubAttributes = Attributes.get_field_root_type("sub_attributes") assert SubAttributes == Attributes.SubAttributes - assert issubclass(SubAttributes, MultiValuedComplexAttribute) + assert issubclass(SubAttributes, ComplexAttribute) assert Attributes.get_field_multiplicity("sub_attributes") assert ( Attributes.model_fields["sub_attributes"].description diff --git a/tests/test_dynamic_schemas.py b/tests/test_dynamic_schemas.py index 289e28e..1f70399 100644 --- a/tests/test_dynamic_schemas.py +++ b/tests/test_dynamic_schemas.py @@ -55,23 +55,6 @@ def test_dynamic_user_schema(load_sample): canonic_schema(schema) canonic_schema(sample) - - # Remove attributes that are redefined from implicit complexattributes - for i, attr in enumerate(sample["attributes"]): - if attr["name"] in ("roles", "entitlements"): - sample["attributes"][i]["subAttributes"] = [ - subattr - for subattr in attr["subAttributes"] - if subattr["name"] not in ("type", "primary", "value", "display") - ] - - if attr["name"] == "x509Certificates": - sample["attributes"][i]["subAttributes"] = [ - subattr - for subattr in attr["subAttributes"] - if subattr["name"] not in ("type", "primary", "display") - ] - assert sample == schema @@ -109,13 +92,6 @@ def test_dynamic_service_provider_config_schema(load_sample): schema["attributes"] = [ attr for attr in schema["attributes"] if attr["name"] != "id" ] - for i, attr in enumerate(schema["attributes"]): - if attr["name"] == "authenticationSchemes": - schema["attributes"][i]["subAttributes"] = [ - subattr - for subattr in attr["subAttributes"] - if subattr["name"] not in ("type", "primary") - ] assert sample == schema