From e007bfcf3be32a6558266f24b66e0cdf0be39205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Fri, 24 May 2024 12:30:24 +0200 Subject: [PATCH] feat: Implement Meta and Resource classes And tests validation of RFC7643 user examples. --- pydantic_scim2/__init__.py | 4 + pydantic_scim2/group.py | 12 ++- pydantic_scim2/resource.py | 122 ++++++++++++++++++++++++++++ pydantic_scim2/responses.py | 5 +- pydantic_scim2/user.py | 14 ++-- tests/test_models.py | 156 ++++++++++++++++++++++++++++++++++++ 6 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 pydantic_scim2/resource.py create mode 100644 tests/test_models.py diff --git a/pydantic_scim2/__init__.py b/pydantic_scim2/__init__.py index 271da5b..c036e3a 100644 --- a/pydantic_scim2/__init__.py +++ b/pydantic_scim2/__init__.py @@ -2,6 +2,8 @@ from .enterprise_user import Manager from .group import Group from .group import GroupMember +from .resource import Meta +from .resource import Resource from .resource_type import ResourceType from .resource_type import SchemaExtension from .responses import ListResponse @@ -66,4 +68,6 @@ "Role", "X509Certificate", "User", + "Resource", + "Meta", ] diff --git a/pydantic_scim2/group.py b/pydantic_scim2/group.py index 39172f8..f648ad2 100644 --- a/pydantic_scim2/group.py +++ b/pydantic_scim2/group.py @@ -1,14 +1,22 @@ from typing import List from typing import Optional -from typing import Tuple +from pydantic import AnyUrl from pydantic import BaseModel +from pydantic import ConfigDict from pydantic import Field class GroupMember(BaseModel): + model_config = ConfigDict(populate_by_name=True) + value: Optional[str] = None display: Optional[str] = None + ref: Optional[AnyUrl] = Field( + None, + alias="$ref", + description="The URI of the SCIM resource representing the User's manager. REQUIRED.", + ) class Group(BaseModel): @@ -19,4 +27,4 @@ class Group(BaseModel): members: Optional[List[GroupMember]] = Field( None, description="A list of members of the Group." ) - schemas: Tuple[str] = ("urn:ietf:params:scim:schemas:core:2.0:Group",) + schemas: List[str] = {"urn:ietf:params:scim:schemas:core:2.0:Group"} diff --git a/pydantic_scim2/resource.py b/pydantic_scim2/resource.py new file mode 100644 index 0000000..edb48b6 --- /dev/null +++ b/pydantic_scim2/resource.py @@ -0,0 +1,122 @@ +from datetime import datetime +from typing import List +from typing import Optional + +from pydantic import BaseModel + + +class Meta(BaseModel): + """All "meta" sub-attributes are assigned by the service provider (have a + "mutability" of "readOnly"), and all of these sub-attributes have a + "returned" characteristic of "default". + + This attribute SHALL be + ignored when provided by clients. "meta" contains the following + sub-attributes: + """ + + resourceType: str + """The name of the resource type of the resource. + + This attribute has a mutability of "readOnly" and "caseExact" as + "true". + """ + + created: datetime + """The "DateTime" that the resource was added to the service provider. + + This attribute MUST be a DateTime. + """ + + lastModified: datetime + """The most recent DateTime that the details of this resource were updated + at the service provider. + + If this resource has never been modified since its initial creation, + the value MUST be the same as the value of "created". + """ + + location: str + """The URI of the resource being returned. + + This value MUST be the same as the "Content-Location" HTTP response + header (see Section 3.1.4.2 of [RFC7231]). + """ + + version: str + """The version of the resource being returned. + + This value must be the same as the entity-tag (ETag) HTTP response + header (see Sections 2.1 and 2.3 of [RFC7232]). This attribute has + "caseExact" as "true". Service provider support for this attribute + is optional and subject to the service provider's support for + versioning (see Section 3.14 of [RFC7644]). If a service provider + provides "version" (entity-tag) for a representation and the + generation of that entity-tag does not satisfy all of the + characteristics of a strong validator (see Section 2.1 of + [RFC7232]), then the origin server MUST mark the "version" (entity- + tag) as weak by prefixing its opaque value with "W/" (case + sensitive). + """ + + +class Resource(BaseModel): + schemas: List[str] + """The "schemas" attribute is a REQUIRED attribute and is an array of + Strings containing URIs that are used to indicate the namespaces of the + SCIM schemas that define the attributes present in the current JSON + structure. + + This attribute may be used by parsers to define the attributes + present in the JSON structure that is the body to an HTTP request or + response. Each String value must be a unique URI. All + representations of SCIM schemas MUST include a non-empty array with + value(s) of the URIs supported by that representation. The + "schemas" attribute for a resource MUST only contain values defined + as "schema" and "schemaExtensions" for the resource's defined + "resourceType". Duplicate values MUST NOT be included. Value order + is not specified and MUST NOT impact behavior. + """ + + # Common attributes as defined by + # https://www.rfc-editor.org/rfc/rfc7643#section-3.1 + id: str + """A unique identifier for a SCIM resource as defined by the service + provider. + + Each representation of the resource MUST include a non-empty "id" + value. This identifier MUST be unique across the SCIM service + provider's entire set of resources. It MUST be a stable, non- + reassignable identifier that does not change when the same resource + is returned in subsequent requests. The value of the "id" attribute + is always issued by the service provider and MUST NOT be specified + by the client. The string "bulkId" is a reserved keyword and MUST + NOT be used within any unique identifier value. The attribute + characteristics are "caseExact" as "true", a mutability of + "readOnly", and a "returned" characteristic of "always". See + Section 9 for additional considerations regarding privacy. + """ + + externalId: Optional[str] = None + """A String that is an identifier for the resource as defined by the + provisioning client. + + The "externalId" may simplify identification of a resource between + the provisioning client and the service provider by allowing the + client to use a filter to locate the resource with an identifier + from the provisioning domain, obviating the need to store a local + mapping between the provisioning domain's identifier of the resource + and the identifier used by the service provider. Each resource MAY + include a non-empty "externalId" value. The value of the + "externalId" attribute is always issued by the provisioning client + and MUST NOT be specified by the service provider. The service + provider MUST always interpret the externalId as scoped to the + provisioning domain. While the server does not enforce uniqueness, + it is assumed that the value's uniqueness is controlled by the + client setting the value. See Section 9 for additional + considerations regarding privacy. This attribute has "caseExact" as + "true" and a mutability of "readWrite". This attribute is OPTIONAL. + """ + + meta: Meta + """A complex attribute containing resource metadata.""" diff --git a/pydantic_scim2/responses.py b/pydantic_scim2/responses.py index 1b1af36..ea73f89 100644 --- a/pydantic_scim2/responses.py +++ b/pydantic_scim2/responses.py @@ -3,7 +3,6 @@ from typing import Any from typing import List from typing import Optional -from typing import Tuple from typing import Union from pydantic import BaseModel @@ -19,7 +18,7 @@ class SCIMError(BaseModel): detail: str status: int - schemas: Tuple[str] = ("urn:ietf:params:scim:api:messages:2.0:Error",) + schemas: List[str] = {"urn:ietf:params:scim:api:messages:2.0:Error"} @classmethod def not_found(cls, detail: str = "Not found") -> "SCIMError": @@ -72,7 +71,7 @@ class ListResponse(BaseModel): Discriminator(get_model_name), ] ] - schemas: Tuple[str] = ("urn:ietf:params:scim:api:messages:2.0:ListResponse",) + schemas: List[str] = {"urn:ietf:params:scim:api:messages:2.0:ListResponse"} @classmethod def for_users( diff --git a/pydantic_scim2/user.py b/pydantic_scim2/user.py index 72b7598..5e5bda0 100644 --- a/pydantic_scim2/user.py +++ b/pydantic_scim2/user.py @@ -1,14 +1,14 @@ from enum import Enum from typing import List from typing import Optional -from typing import Set from pydantic import AnyUrl from pydantic import BaseModel from pydantic import EmailStr from pydantic import Field -from pydantic_scim2.group import GroupMember +from .group import GroupMember +from .resource import Resource class Name(BaseModel): @@ -163,6 +163,10 @@ class Address(BaseModel): None, description="A label indicating the attribute's function, e.g., 'work' or 'home'.", ) + primary: Optional[bool] = Field( + None, + description="A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred photo or thumbnail. The primary attribute value 'true' MUST appear no more than once.", + ) class Entitlement(BaseModel): @@ -210,13 +214,11 @@ class X509Certificate(BaseModel): ) -class User(BaseModel): +class User(Resource): userName: str = Field( ..., description="Unique identifier for the User, typically used by the user to directly authenticate to the service provider. Each User MUST include a non-empty userName value. This identifier MUST be unique across the service provider's entire set of Users. REQUIRED.", ) - # Seems required by okta, but not in the json schema spec. - id: Optional[str] = None name: Optional[Name] = Field( None, description="The components of the user's real name. Providers MAY return just the full name as a single string in the formatted sub-attribute, or they MAY return just the individual component attributes using the other sub-attributes, or they MAY return both. If both variants are returned, they SHOULD be describing the same name, with the formatted name indicating how the component attributes should be combined.", @@ -292,4 +294,4 @@ class User(BaseModel): x509Certificates: Optional[List[X509Certificate]] = Field( None, description="A list of certificates issued to the User." ) - schemas: Set[str] = ("urn:ietf:params:scim:schemas:core:2.0:User",) + schemas: List[str] = {"urn:ietf:params:scim:schemas:core:2.0:User"} diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..60795e8 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,156 @@ +import datetime + +from pydantic import AnyUrl + +from pydantic_scim2 import Address +from pydantic_scim2 import AddressKind +from pydantic_scim2 import Email +from pydantic_scim2 import EmailKind +from pydantic_scim2 import GroupMember +from pydantic_scim2 import Im +from pydantic_scim2 import ImKind +from pydantic_scim2 import Meta +from pydantic_scim2 import Name +from pydantic_scim2 import PhoneNumber +from pydantic_scim2 import PhoneNumberKind +from pydantic_scim2 import Photo +from pydantic_scim2 import PhotoKind +from pydantic_scim2 import User +from pydantic_scim2 import X509Certificate + + +def test_minimal_user(minimal_user_payload): + obj = User.model_validate(minimal_user_payload) + + assert obj.schemas == ["urn:ietf:params:scim:schemas:core:2.0:User"] + assert obj.id == "2819c223-7f76-453a-919d-413861904646" + assert obj.userName == "bjensen@example.com" + assert obj.meta.resourceType == "User" + assert obj.meta.created == datetime.datetime( + 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc + ) + assert obj.meta.lastModified == datetime.datetime( + 2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc + ) + assert obj.meta.version == 'W\\/"3694e05e9dff590"' + assert ( + obj.meta.location + == "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" + ) + + +def test_full_user(full_user_payload): + obj = User.model_validate(full_user_payload) + + assert obj.schemas == ["urn:ietf:params:scim:schemas:core:2.0:User"] + assert obj.id == "2819c223-7f76-453a-919d-413861904646" + assert obj.externalId == "701984" + assert obj.userName == "bjensen@example.com" + assert obj.name == Name( + formatted="Ms. Barbara J Jensen, III", + familyName="Jensen", + givenName="Barbara", + middleName="Jane", + honorificPrefix="Ms.", + honorificSuffix="III", + ) + assert obj.displayName == "Babs Jensen" + assert obj.nickName == "Babs" + assert obj.profileUrl == AnyUrl("https://login.example.com/bjensen") + assert obj.emails == [ + Email(value="bjensen@example.com", type=EmailKind.work, primary=True), + Email(value="babs@jensen.org", type=EmailKind.home), + ] + assert obj.addresses == [ + Address( + type=AddressKind.work, + streetAddress="100 Universal City Plaza", + locality="Hollywood", + region="CA", + postalCode="91608", + country="USA", + formatted="100 Universal City Plaza\nHollywood, CA 91608 USA", + primary=True, + ), + Address( + type=AddressKind.home, + streetAddress="456 Hollywood Blvd", + locality="Hollywood", + region="CA", + postalCode="91608", + country="USA", + formatted="456 Hollywood Blvd\nHollywood, CA 91608 USA", + ), + ] + assert obj.phoneNumbers == [ + PhoneNumber(value="555-555-5555", type=PhoneNumberKind.work), + PhoneNumber(value="555-555-4444", type=PhoneNumberKind.mobile), + ] + assert obj.ims == [Im(value="someaimhandle", type=ImKind.aim)] + assert obj.photos == [ + Photo( + value="https://photos.example.com/profilephoto/72930000000Ccne/F", + type=PhotoKind.photo, + ), + Photo( + value="https://photos.example.com/profilephoto/72930000000Ccne/T", + type=PhotoKind.thumbnail, + ), + ] + assert obj.userType == "Employee" + assert obj.title == "Tour Guide" + assert obj.preferredLanguage == "en-US" + assert obj.locale == "en-US" + assert obj.timezone == "America/Los_Angeles" + assert obj.active is True + assert obj.password == "t1meMa$heen" + assert obj.groups == [ + GroupMember( + value="e9e30dba-f08f-4109-8486-d5c6a331660a", + ref="https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", + display="Tour Guides", + ), + GroupMember( + value="fc348aa8-3835-40eb-a20b-c726e15c55b5", + ref="https://example.com/v2/Groups/fc348aa8-3835-40eb-a20b-c726e15c55b5", + display="Employees", + ), + GroupMember( + value="71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7", + ref="https://example.com/v2/Groups/71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7", + display="US Employees", + ), + ] + assert obj.x509Certificates == [ + X509Certificate( + value=( + "MIIDQzCCAqygAwIBAgICEAAwDQYJKoZIhvcNAQEFBQAwTjELMAkGA1UEBhMCVVMx" + "EzARBgNVBAgMCkNhbGlmb3JuaWExFDASBgNVBAoMC2V4YW1wbGUuY29tMRQwEgYD" + "VQQDDAtleGFtcGxlLmNvbTAeFw0xMTEwMjIwNjI0MzFaFw0xMjEwMDQwNjI0MzFa" + "MH8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRQwEgYDVQQKDAtl" + "eGFtcGxlLmNvbTEhMB8GA1UEAwwYTXMuIEJhcmJhcmEgSiBKZW5zZW4gSUlJMSIw" + "IAYJKoZIhvcNAQkBFhNiamVuc2VuQGV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0B" + "AQEFAAOCAQ8AMIIBCgKCAQEA7Kr+Dcds/JQ5GwejJFcBIP682X3xpjis56AK02bc" + "1FLgzdLI8auoR+cC9/Vrh5t66HkQIOdA4unHh0AaZ4xL5PhVbXIPMB5vAPKpzz5i" + "PSi8xO8SL7I7SDhcBVJhqVqr3HgllEG6UClDdHO7nkLuwXq8HcISKkbT5WFTVfFZ" + "zidPl8HZ7DhXkZIRtJwBweq4bvm3hM1Os7UQH05ZS6cVDgweKNwdLLrT51ikSQG3" + "DYrl+ft781UQRIqxgwqCfXEuDiinPh0kkvIi5jivVu1Z9QiwlYEdRbLJ4zJQBmDr" + "SGTMYn4lRc2HgHO4DqB/bnMVorHB0CC6AV1QoFK4GPe1LwIDAQABo3sweTAJBgNV" + "HRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZp" + "Y2F0ZTAdBgNVHQ4EFgQU8pD0U0vsZIsaA16lL8En8bx0F/gwHwYDVR0jBBgwFoAU" + "dGeKitcaF7gnzsNwDx708kqaVt0wDQYJKoZIhvcNAQEFBQADgYEAA81SsFnOdYJt" + "Ng5Tcq+/ByEDrBgnusx0jloUhByPMEVkoMZ3J7j1ZgI8rAbOkNngX8+pKfTiDz1R" + "C4+dx8oU6Za+4NJXUjlL5CvV6BEYb1+QAEJwitTVvxB/A67g42/vzgAtoRUeDov1" + "+GFiBZ+GNF/cAYKcMtGcrs2i97ZkJMo=" + ) + ) + ] + assert obj.meta == Meta( + resourceType="User", + created=datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc), + lastModified=datetime.datetime( + 2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc + ), + version='W\\/"a330bc54f0671c9"', + location="https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + )