├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── LICENSE ├── README.md ├── conftest.py ├── doc ├── __init__.py ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── reference.rst └── tutorial.rst ├── pyproject.toml ├── samples ├── rfc7643-8.1-user-minimal.json ├── rfc7643-8.2-user-full.json ├── rfc7643-8.3-enterprise_user.json ├── rfc7643-8.4-group.json ├── rfc7643-8.5-service_provider_configuration.json ├── rfc7643-8.6-resource_type-group.json ├── rfc7643-8.6-resource_type-user.json ├── rfc7643-8.7.1-schema-enterprise_user.json ├── rfc7643-8.7.1-schema-group.json ├── rfc7643-8.7.1-schema-user.json ├── rfc7643-8.7.2-schema-resource_type.json ├── rfc7643-8.7.2-schema-schema.json ├── rfc7643-8.7.2-schema-service_provider_configuration.json ├── rfc7644-3.12-error-bad_request.json ├── rfc7644-3.12-error-not_found.json ├── rfc7644-3.14-user-post_request.json ├── rfc7644-3.14-user-post_response.json ├── rfc7644-3.3-user-post_request.json ├── rfc7644-3.3-user-post_response.json ├── rfc7644-3.4.1-user-known-resource.json ├── rfc7644-3.4.2-list_response-partial_attributes.json ├── rfc7644-3.4.3-list_response-post_query.json ├── rfc7644-3.4.3-search_request.json ├── rfc7644-3.5.1-user-put_request.json ├── rfc7644-3.5.1-user-put_response.json ├── rfc7644-3.5.2.1-patch_op-add_emails.json ├── rfc7644-3.5.2.1-patch_op-add_members.json ├── rfc7644-3.5.2.2-patch_op-remove_all_members.json ├── rfc7644-3.5.2.2-patch_op-remove_and_add_one_member.json ├── rfc7644-3.5.2.2-patch_op-remove_multi_complex_value.json ├── rfc7644-3.5.2.2-patch_op-remove_one_member.json ├── rfc7644-3.5.2.3-patch_op-replace_all_email_values.json ├── rfc7644-3.5.2.3-patch_op-replace_all_members.json ├── rfc7644-3.5.2.3-patch_op-replace_street_address.json ├── rfc7644-3.5.2.3-patch_op-replace_user_work_address.json ├── rfc7644-3.6-error-not_found.json ├── rfc7644-3.7.1-bulk_request-circular_conflict.json ├── rfc7644-3.7.1-list_response-circular_reference.json ├── rfc7644-3.7.2-bulk_request-enterprise_user.json ├── rfc7644-3.7.2-bulk_request-temporary_identifier.json ├── rfc7644-3.7.2-bulk_response-temporary_identifier.json ├── rfc7644-3.7.3-bulk_request-multiple_operations.json ├── rfc7644-3.7.3-bulk_response-error_invalid_syntax.json ├── rfc7644-3.7.3-bulk_response-multiple_errors.json ├── rfc7644-3.7.3-bulk_response-multiple_operations.json ├── rfc7644-3.7.3-error-invalid_syntax.json ├── rfc7644-3.7.4-error-payload_too_large.json ├── rfc7644-3.9-user-partial_response.json └── rfc7644-4-list_response-resource_types.json ├── scim2_models ├── __init__.py ├── base.py ├── constants.py ├── py.typed ├── rfc7643 │ ├── __init__.py │ ├── enterprise_user.py │ ├── group.py │ ├── resource.py │ ├── resource_type.py │ ├── schema.py │ ├── service_provider_config.py │ └── user.py ├── rfc7644 │ ├── __init__.py │ ├── bulk.py │ ├── error.py │ ├── list_response.py │ ├── message.py │ ├── patch_op.py │ └── search_request.py └── utils.py ├── tests ├── __init__.py ├── conftest.py ├── test_dynamic_resources.py ├── test_dynamic_schemas.py ├── test_enterprise_user.py ├── test_errors.py ├── test_group.py ├── test_list_response.py ├── test_model_attributes.py ├── test_model_serialization.py ├── test_model_validation.py ├── test_models.py ├── test_patch_op.py ├── test_resource_extension.py ├── test_resource_type.py ├── test_schema.py ├── test_search_request.py ├── test_service_provider_configuration.py ├── test_user.py └── test_utils.py └── uv.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: [yaal-coop] 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: bundle 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | name: build dist files 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v3 17 | with: 18 | enable-cache: true 19 | - name: build dist 20 | run: uv build 21 | - uses: actions/upload-artifact@v4 22 | with: 23 | name: artifacts 24 | path: dist/* 25 | if-no-files-found: error 26 | 27 | release: 28 | name: create Github release 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: softprops/action-gh-release@v2 32 | 33 | publish: 34 | name: release to pypi 35 | needs: build 36 | runs-on: ubuntu-latest 37 | 38 | environment: 39 | name: pypi-release 40 | url: https://pypi.org/project/scim2-models/ 41 | permissions: 42 | id-token: write 43 | 44 | steps: 45 | - uses: actions/download-artifact@v4 46 | with: 47 | name: artifacts 48 | path: dist 49 | 50 | - name: Push build artifacts to PyPI 51 | uses: pypa/gh-action-pypi-publish@release/v1 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - '*.*.*' 8 | pull_request: 9 | branches: 10 | - main 11 | - '*.*.*' 12 | 13 | jobs: 14 | tests: 15 | name: py${{ matrix.python }} unit tests 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python: 21 | - '3.13' 22 | - '3.12' 23 | - '3.11' 24 | - '3.10' 25 | - '3.9' 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Install uv 29 | uses: astral-sh/setup-uv@v3 30 | with: 31 | enable-cache: true 32 | - name: Install Python ${{ matrix.python }} 33 | run: uv python install ${{ matrix.python }} 34 | - name: Run tests 35 | run: uv run pytest --showlocals 36 | 37 | downstream-tests: 38 | name: py${{ matrix.python }} ${{ matrix.downstream }} downstream unit tests 39 | runs-on: ubuntu-latest 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | python: 44 | - '3.13' 45 | - '3.12' 46 | - '3.11' 47 | downstream: 48 | - scim2-client 49 | - scim2-server 50 | - scim2-cli 51 | - scim2-tester 52 | steps: 53 | - name: Checkout upstream pyproject 54 | uses: actions/checkout@v4 55 | - name: Install uv 56 | uses: astral-sh/setup-uv@v3 57 | with: 58 | enable-cache: true 59 | - name: Checkout downstream pyproject 60 | uses: actions/checkout@v4 61 | with: 62 | repository: python-scim/${{ matrix.downstream }} 63 | path: ${{ matrix.downstream }} 64 | - name: Install Python ${{ matrix.python }} 65 | run: | 66 | cd ${{ matrix.downstream }} 67 | uv python install ${{ matrix.python }} 68 | - name: Install downstream test environment 69 | run: | 70 | cd ${{ matrix.downstream }} 71 | uv sync --all-extras 72 | uv pip install --upgrade --force-reinstall .. 73 | - name: Run downstream tests 74 | run: | 75 | cd ${{ matrix.downstream }} 76 | uv run pytest --showlocals 77 | 78 | minversions: 79 | name: minimum dependency versions 80 | runs-on: ubuntu-latest 81 | steps: 82 | - uses: actions/checkout@v4 83 | - name: Install uv 84 | uses: astral-sh/setup-uv@v3 85 | with: 86 | enable-cache: true 87 | - name: Install minimum dependencies 88 | run: uv sync --resolution=lowest-direct 89 | - name: Run tests 90 | run: uv run pytest --showlocals 91 | 92 | style: 93 | runs-on: ubuntu-latest 94 | steps: 95 | - uses: actions/checkout@v4 96 | - uses: pre-commit/action@v3.0.1 97 | 98 | doc: 99 | runs-on: ubuntu-latest 100 | steps: 101 | - uses: actions/checkout@v4 102 | - name: Install uv 103 | uses: astral-sh/setup-uv@v3 104 | with: 105 | enable-cache: true 106 | - name: Install dependencies 107 | run: uv sync --group doc 108 | - run: uv run sphinx-build doc build/sphinx/html 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.sqlite 3 | *.pyc 4 | *.mo 5 | *.prof 6 | .python_history 7 | .tox 8 | .coverage 9 | .coverage.* 10 | htmlcov 11 | *.egg-info 12 | build 13 | dist 14 | .vscode 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: 'v0.11.10' 5 | hooks: 6 | - id: ruff 7 | args: [--fix, --exit-non-zero-on-fix] 8 | - id: ruff-format 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v5.0.0 11 | hooks: 12 | - id: fix-byte-order-marker 13 | - id: trailing-whitespace 14 | exclude: "\\.svg$|\\.map$|\\.min\\.css$|\\.min\\.js$|\\.po$|\\.pot$" 15 | - id: end-of-file-fixer 16 | exclude: "\\.svg$|\\.map$|\\.min\\.css$|\\.min\\.js$|\\.po$|\\.pot$" 17 | - id: check-toml 18 | - repo: https://github.com/pre-commit/mirrors-mypy 19 | rev: v1.15.0 20 | hooks: 21 | - id: mypy 22 | - repo: https://github.com/codespell-project/codespell 23 | rev: v2.4.1 24 | hooks: 25 | - id: codespell 26 | additional_dependencies: 27 | - tomli 28 | args: [--write-changes] 29 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | sphinx: 4 | configuration: doc/conf.py 5 | build: 6 | os: "ubuntu-22.04" 7 | tools: 8 | python: "3.12" 9 | jobs: 10 | post_create_environment: 11 | - pip install uv 12 | - uv export --group doc --no-hashes --output-file requirements.txt 13 | post_install: 14 | - pip install . 15 | - pip install --requirement requirements.txt 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scim2-models 2 | 3 | [Pydantic](https://docs.pydantic.dev) models for SCIM schemas defined in [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643.html) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644.html). 4 | 5 | This library provides utilities to parse and produce SCIM2 payloads, and handle them with native Python objects. 6 | It aims to be used as a basis to build SCIM2 servers and clients. 7 | 8 | ## What's SCIM anyway? 9 | 10 | SCIM stands for System for Cross-domain Identity Management, and it is a provisioning protocol. 11 | Provisioning is the action of managing a set of resources across different services, usually users and groups. 12 | SCIM is often used between Identity Providers and applications in completion of standards like OAuth2 and OpenID Connect. 13 | It allows users and groups creations, modifications and deletions to be synchronized between applications. 14 | 15 | ## Installation 16 | 17 | ```shell 18 | pip install scim2-models 19 | ``` 20 | 21 | ## Usage 22 | 23 | Check the [tutorial](https://scim2-models.readthedocs.io/en/latest/tutorial.html) and the [reference](https://scim2-models.readthedocs.io/en/latest/reference.html) for more details. 24 | 25 | ```python 26 | from scim2_models import User 27 | import datetime 28 | 29 | payload = { 30 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], 31 | "id": "2819c223-7f76-453a-919d-413861904646", 32 | "userName": "bjensen@example.com", 33 | "meta": { 34 | "resourceType": "User", 35 | "created": "2010-01-23T04:56:22Z", 36 | "lastModified": "2011-05-13T04:42:34Z", 37 | "version": 'W\\/"3694e05e9dff590"', 38 | "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", 39 | }, 40 | } 41 | 42 | user = User.model_validate(payload) 43 | assert user.user_name == "bjensen@example.com" 44 | assert user.meta.created == datetime.datetime( 45 | 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc 46 | ) 47 | ``` 48 | 49 | scim2-models belongs in a collection of SCIM tools developed by [Yaal Coop](https://yaal.coop), 50 | with [scim2-client](https://github.com/python-scim/scim2-client), 51 | [scim2-tester](https://github.com/python-scim/scim2-tester) and 52 | [scim2-cli](https://github.com/python-scim/scim2-cli) 53 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | import pytest 3 | 4 | import scim2_models 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def add_doctest_namespace(doctest_namespace): 9 | doctest_namespace["pydantic"] = pydantic 10 | imports = {item: getattr(scim2_models, item) for item in scim2_models.__all__} 11 | doctest_namespace.update(imports) 12 | return doctest_namespace 13 | -------------------------------------------------------------------------------- /doc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-scim/scim2-models/61fc8b0f56aca93bcde078dc43a2891c026fb0d7/doc/__init__.py -------------------------------------------------------------------------------- /doc/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | [0.3.6] - 2025-07-02 5 | -------------------- 6 | 7 | Added 8 | ^^^^^ 9 | - Fix :meth:`ResourceType.from_resource ` 10 | usage for resources with several extensions. :pr:`95` 11 | 12 | [0.3.5] - 2025-06-05 13 | -------------------- 14 | 15 | Added 16 | ^^^^^ 17 | - Fix dynamic schema generation for user defined classes with inheritance. 18 | 19 | [0.3.4] - 2025-06-05 20 | -------------------- 21 | 22 | Added 23 | ^^^^^ 24 | - Implement User and Group attributes types shortcuts to match dynamically created model types. 25 | 26 | [0.3.3] - 2025-05-21 27 | -------------------- 28 | 29 | Fixed 30 | ^^^^^ 31 | - User class typing. :pr:`92` 32 | 33 | [0.3.2] - 2025-03-28 34 | -------------------- 35 | 36 | Fixed 37 | ^^^^^ 38 | - Pydantic warning. 39 | 40 | [0.3.1] - 2025-03-07 41 | -------------------- 42 | 43 | Fixed 44 | ^^^^^ 45 | - Fix :attr:`~scim2_models.SearchRequest.start_index` and :attr:`~scim2_models.SearchRequest.count` limits. :issue:`84` 46 | - :attr:`~scim2_models.ListResponse.total_resuls` is required. :issue:`88` 47 | 48 | [0.3.0] - 2024-12-11 49 | -------------------- 50 | 51 | Added 52 | ^^^^^ 53 | - :meth:`Attribute.get_attribute ` can be called with brackets. 54 | 55 | Changed 56 | ^^^^^^^ 57 | - Add a :paramref:`~scim2_models.BaseModel.model_validate.original` 58 | parameter to :meth:`~scim2_models.BaseModel.model_validate` 59 | mandatory for :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`. 60 | This *original* value is used to look if :attr:`~scim2_models.Mutability.immutable` 61 | parameters have mutated. 62 | :issue:`86` 63 | 64 | [0.2.12] - 2024-12-09 65 | --------------------- 66 | 67 | Added 68 | ^^^^^ 69 | - Implement :meth:`Attribute.get_attribute `. 70 | 71 | [0.2.11] - 2024-12-08 72 | --------------------- 73 | 74 | Added 75 | ^^^^^ 76 | - Implement :meth:`Schema.get_attribute `. 77 | - Implement :meth:`SearchRequest.start_index_0 ` 78 | and :meth:`SearchRequest.start_index_1 `. 79 | 80 | [0.2.10] - 2024-12-02 81 | --------------------- 82 | 83 | Changed 84 | ^^^^^^^ 85 | - The ``schema`` attribute is annotated with :attr:`~scim2_models.Required.true`. 86 | 87 | Fixed 88 | ^^^^^ 89 | - ``Base64Bytes`` compatibility between pydantic 2.10+ and <2.10 90 | 91 | [0.2.9] - 2024-12-02 92 | -------------------- 93 | 94 | Added 95 | ^^^^^ 96 | - Implement :meth:`Resource.get_extension_model `. 97 | 98 | [0.2.8] - 2024-12-02 99 | -------------------- 100 | 101 | Added 102 | ^^^^^ 103 | - Support for Pydantic 2.10. 104 | 105 | [0.2.7] - 2024-11-30 106 | -------------------- 107 | 108 | Added 109 | ^^^^^ 110 | - Implement :meth:`ResourceType.from_resource `. 111 | 112 | [0.2.6] - 2024-11-29 113 | -------------------- 114 | 115 | Fixed 116 | ^^^^^ 117 | - Implement :meth:`~scim2_models.BaseModel.model_dump_json`. 118 | - Temporarily set Pydantic 2.9 as the maximum supported version. 119 | 120 | [0.2.5] - 2024-11-13 121 | -------------------- 122 | 123 | Fixed 124 | ^^^^^ 125 | - :meth:`~scim2_models.BaseModel.model_validate` types. 126 | 127 | [0.2.4] - 2024-11-03 128 | -------------------- 129 | 130 | Fixed 131 | ^^^^^ 132 | - Python 3.9 and 3.10 compatibility. 133 | 134 | [0.2.3] - 2024-11-01 135 | -------------------- 136 | 137 | Added 138 | ^^^^^ 139 | - Python 3.13 support. 140 | - Proper Base64 serialization. :issue:`31` 141 | - :meth:`~BaseModel.get_field_root_type` supports :data:`~typing.UnionType`. 142 | 143 | Changed 144 | ^^^^^^^ 145 | - :attr:`SearchRequest.attributes ` and :attr:`SearchRequest.attributes ` are mutually exclusive. :issue:`19` 146 | - :class:`~scim2_models.Schema` ids must be valid URIs. :issue:`26` 147 | 148 | [0.2.2] - 2024-09-20 149 | -------------------- 150 | 151 | Fixed 152 | ^^^^^ 153 | - :class:`~scim2_models.ListResponse` pydantic discriminator issue introduced with pydantic 2.9.0. :issue:`75` 154 | - Extension payloads are not required on response contexts. :issue:`77` 155 | 156 | [0.2.1] - 2024-09-06 157 | -------------------- 158 | 159 | Fixed 160 | ^^^^^ 161 | - :attr:`~scim2_models.Resource.external_id` is :data:`scim2_models.CaseExact.true`. :issue:`74` 162 | 163 | [0.2.0] - 2024-08-18 164 | -------------------- 165 | 166 | Fixed 167 | ^^^^^ 168 | - Fix the extension mechanism by introducing the :class:`~scim2_models.Extension` class. :issue:`60`, :issue:`63` 169 | 170 | .. note:: 171 | 172 | ``schema.make_model()`` becomes ``Resource.from_schema(schema)`` or ``Extension.from_schema(schema)``. 173 | 174 | Changed 175 | ^^^^^^^ 176 | - Enable pydantic :attr:`~pydantic.config.ConfigDict.validate_assignment` option. :issue:`54` 177 | 178 | [0.1.15] - 2024-08-18 179 | --------------------- 180 | 181 | Added 182 | ^^^^^ 183 | - Add a PEP561 ``py.typed`` file to mark the package as typed. 184 | 185 | Fixed 186 | ^^^^^ 187 | - :class:`scim2_models.Manager` is a :class:`~scim2_models.MultiValuedComplexAttribute`. :issue:`62` 188 | 189 | Changed 190 | ^^^^^^^ 191 | - Remove :class:`~scim2_models.ListResponse` ``of`` method in favor of regular type parameters. 192 | 193 | .. note:: 194 | 195 | ``ListResponse.of(User)`` becomes ``ListResponse[User]`` and ListResponse.of(User, Group)`` becomes ``ListResponse[Union[User, Group]]``. 196 | 197 | - :data:`~scim2_models.Reference` use :data:`~typing.Literal` instead of :class:`typing.ForwardRef`. 198 | 199 | .. note:: 200 | 201 | ``pet: Reference["Pet"]`` becomes ``pet: Reference[Literal["Pet"]]`` 202 | 203 | [0.1.14] - 2024-07-23 204 | --------------------- 205 | 206 | Fixed 207 | ^^^^^ 208 | - `get_by_payload` return :data:`None` on invalid payloads 209 | - instance :meth:`~scim2_models.Resource.model_dump` with multiple extensions :issue:`57` 210 | 211 | [0.1.13] - 2024-07-15 212 | --------------------- 213 | 214 | Fixed 215 | ^^^^^ 216 | - Schema dump with context was broken. 217 | - :attr:`scim2_models.PatchOperation.op` attribute is case insensitive to be compatible with Microsoft Entra. :issue:`55` 218 | 219 | [0.1.12] - 2024-07-11 220 | --------------------- 221 | 222 | Fixed 223 | ^^^^^ 224 | - Additional bugfixes about attribute case sensitivity :issue:`45` 225 | - Dump was broken after sub-model assignments :issue:`48` 226 | - Extension attributes dump were ignored :issue:`49` 227 | - :class:`~scim2_models.ListResponse` tolerate any schema order :issue:`50` 228 | 229 | [0.1.11] - 2024-07-02 230 | --------------------- 231 | 232 | Fixed 233 | ^^^^^ 234 | - Attributes are case insensitive :issue:`39` 235 | 236 | [0.1.10] - 2024-06-30 237 | --------------------- 238 | 239 | Added 240 | ^^^^^ 241 | - Export resource models with :data:`~scim2_models.Resource.to_schema` :issue:`7` 242 | 243 | [0.1.9] - 2024-06-29 244 | -------------------- 245 | 246 | Added 247 | ^^^^^ 248 | - :data:`~scim2_models.Reference` type parameters represent SCIM ReferenceType 249 | 250 | Fixed 251 | ^^^^^ 252 | - :attr:`~scim2_models.SearchRequest.count` and :attr:`~scim2_models.SearchRequest.start_index` validators 253 | supports :data:`None` values. 254 | 255 | [0.1.8] - 2024-06-26 256 | -------------------- 257 | 258 | Added 259 | ^^^^^ 260 | - Dynamic pydantic model creation from SCIM schemas. :issue:`6` 261 | 262 | Changed 263 | ^^^^^^^ 264 | - Use a custom :data:`~scim2_models.Reference` type instead of :class:`~pydantic.AnyUrl` as RFC7643 reference type. 265 | 266 | Fix 267 | ^^^ 268 | - Allow relative URLs in :data:`~scim2_models.Reference`. 269 | - Models with multiples extensions could not be initialized. :issue:`37` 270 | 271 | [0.1.7] - 2024-06-16 272 | -------------------- 273 | 274 | Added 275 | ^^^^^ 276 | - :attr:`~scim2_models.SearchRequest.count` value is floored to 1 277 | - :attr:`~scim2_models.SearchRequest.start_index` value is floored to 0 278 | - :attr:`~scim2_models.ListResponse.resources` must be set when :attr:`~scim2_models.ListResponse.totalResults` is non-null. 279 | 280 | Fix 281 | ^^^ 282 | - Add missing default values. :issue:`33` 283 | 284 | [0.1.6] - 2024-06-06 285 | -------------------- 286 | 287 | Added 288 | ^^^^^ 289 | - Implement :class:`~scim2_models.CaseExact` attributes annotations. 290 | - Implement :class:`~scim2_models.Required` attributes annotations validation. 291 | 292 | Changed 293 | ^^^^^^^ 294 | - Refactor :code:`get_field_mutability` and :code:`get_field_returnability` in :code:`get_field_annotation`. 295 | 296 | [0.1.5] - 2024-06-04 297 | -------------------- 298 | 299 | Fix 300 | ^^^ 301 | - :class:`~scim2_models.Schema` is a :class:`~scim2_models.Resource`. 302 | 303 | [0.1.4] - 2024-06-03 304 | -------------------- 305 | 306 | Fix 307 | ^^^ 308 | - :code:`ServiceProviderConfiguration` `id` is optional. 309 | 310 | [0.1.3] - 2024-06-03 311 | -------------------- 312 | 313 | Changed 314 | ^^^^^^^ 315 | - Rename :code:`ServiceProviderConfiguration` to :code:`ServiceProviderConfig` to match the RFCs naming convention. 316 | 317 | [0.1.2] - 2024-06-02 318 | -------------------- 319 | 320 | Added 321 | ^^^^^ 322 | - Implement :meth:`~scim2_models.Resource.guess_by_payload` 323 | 324 | [0.1.1] - 2024-06-01 325 | -------------------- 326 | 327 | Changed 328 | ^^^^^^^ 329 | - Pre-defined errors are not constants anymore 330 | 331 | [0.1.0] - 2024-06-01 332 | -------------------- 333 | 334 | Added 335 | ^^^^^ 336 | - Initial release 337 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import sys 4 | from importlib import metadata 5 | 6 | sys.path.insert(0, os.path.abspath("..")) 7 | sys.path.insert(0, os.path.abspath("../scim2_models")) 8 | 9 | # -- General configuration ------------------------------------------------ 10 | 11 | extensions = [ 12 | "sphinx.ext.autodoc", 13 | "sphinx.ext.doctest", 14 | "sphinx.ext.graphviz", 15 | "sphinx.ext.intersphinx", 16 | "sphinx.ext.todo", 17 | "sphinx.ext.viewcode", 18 | "sphinxcontrib.autodoc_pydantic", 19 | "sphinx_issues", 20 | "sphinx_paramlinks", 21 | "sphinx_togglebutton", 22 | "myst_parser", 23 | ] 24 | 25 | templates_path = ["_templates"] 26 | master_doc = "index" 27 | project = "scim2-models" 28 | year = datetime.datetime.now().strftime("%Y") 29 | copyright = f"{year}, Yaal Coop" 30 | author = "Yaal Coop" 31 | source_suffix = { 32 | ".rst": "restructuredtext", 33 | ".txt": "markdown", 34 | ".md": "markdown", 35 | } 36 | 37 | version = metadata.version("scim2-models") 38 | language = "en" 39 | pygments_style = "sphinx" 40 | todo_include_todos = True 41 | toctree_collapse = False 42 | 43 | intersphinx_mapping = { 44 | "python": ("https://docs.python.org/3", None), 45 | "pydantic": ("https://docs.pydantic.dev/latest/", None), 46 | } 47 | 48 | # -- Options for HTML output ---------------------------------------------- 49 | 50 | html_theme = "shibuya" 51 | # html_static_path = ["_static"] 52 | html_baseurl = "https://scim2-models.readthedocs.io" 53 | html_theme_options = { 54 | "globaltoc_expand_depth": 3, 55 | "accent_color": "amber", 56 | "github_url": "https://github.com/python-scim/scim2-models", 57 | "mastodon_url": "https://toot.aquilenet.fr/@yaal", 58 | "nav_links": [ 59 | {"title": "scim2-client", "url": "https://scim2-client.readthedocs.io"}, 60 | {"title": "scim2-tester", "url": "https://scim2-tester.readthedocs.io"}, 61 | { 62 | "title": "scim2-cli", 63 | "url": "https://scim2-cli.readthedocs.io", 64 | }, 65 | ], 66 | } 67 | html_context = { 68 | "source_type": "github", 69 | "source_user": "python-scim", 70 | "source_repo": "scim2-models", 71 | "source_version": "main", 72 | "source_docs_path": "/doc/", 73 | } 74 | 75 | # -- Options for autodoc_pydantic_settings ------------------------------------------- 76 | 77 | autodoc_pydantic_model_show_config_summary = False 78 | autodoc_pydantic_model_show_field_summary = False 79 | autodoc_pydantic_model_show_json = False 80 | autodoc_pydantic_model_show_validator_summary = False 81 | autodoc_pydantic_model_show_validator_members = False 82 | autodoc_pydantic_field_show_constraints = False 83 | autodoc_pydantic_field_list_validators = False 84 | autodoc_pydantic_field_doc_policy = "docstring" 85 | 86 | # -- Options for doctest ------------------------------------------- 87 | 88 | doctest_global_setup = """ 89 | from scim2_models import * 90 | """ 91 | 92 | # -- Options for sphinx-issues ------------------------------------- 93 | 94 | issues_github_path = "python-scim/scim2-models" 95 | -------------------------------------------------------------------------------- /doc/contributing.rst: -------------------------------------------------------------------------------- 1 | Contribution 2 | ============ 3 | 4 | Contributions are welcome! 5 | 6 | The repository is hosted at `github.com/python-scim/scim2-models `_. 7 | 8 | Discuss 9 | ------- 10 | 11 | If you want to implement a feature or a bugfix, please start by discussing it with us on 12 | the `bugtracker `_. 13 | 14 | Unit tests 15 | ---------- 16 | 17 | To run the tests, you just can run `uv run pytest` and/or `tox` to test all the supported python environments. 18 | Everything must be green before patches get merged. 19 | 20 | The test coverage is 100%, patches won't be accepted if not entirely covered. You can check the 21 | test coverage with ``uv run pytest --cov --cov-report=html`` or ``tox -e coverage -- --cov-report=html``. 22 | You can check the HTML coverage report in the newly created `htmlcov` directory. 23 | 24 | Code style 25 | ---------- 26 | 27 | We use `ruff `_ along with other tools to format our code. 28 | Please run ``tox -e style`` on your patches before submitting them. 29 | In order to perform a style check and correction at each commit you can use our 30 | `pre-commit `_ configuration with ``pre-commit install``. 31 | 32 | Documentation 33 | ------------- 34 | 35 | The documentation is generated when the tests run: 36 | 37 | .. code-block:: bash 38 | 39 | tox -e doc 40 | 41 | You can also run sphinx by hand, that should be faster since it avoids the tox environment initialization: 42 | 43 | .. code-block:: bash 44 | 45 | sphinx-build doc build/sphinx/html 46 | 47 | The generated documentation is located at ``build/sphinx/html``. 48 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.md 2 | :parser: myst_parser.parsers.docutils_ 3 | 4 | Table of contents 5 | ----------------- 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | tutorial 11 | reference 12 | contributing 13 | changelog 14 | 15 | scim2-models is a fork of `pydantic-scim `_ to bring support for pydantic 2. 16 | -------------------------------------------------------------------------------- /doc/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | This page presents all the models provided by scim2-models. 5 | 6 | .. data:: scim2_models.AnyResource 7 | :type: typing.TypeVar 8 | 9 | Type bound to any subclass of :class:`~scim2_models.Resource`. 10 | 11 | .. data:: scim2_models.ExternalReference 12 | :type: typing.Type 13 | 14 | External reference type as described in :rfc:`RFC7643 §7 <7643#section-7>`. 15 | 16 | .. data:: scim2_models.URIReference 17 | :type: typing.Type 18 | 19 | URI reference type as described in :rfc:`RFC7643 §7 <7643#section-7>`. 20 | 21 | .. automodule:: scim2_models 22 | :members: 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "scim2-models" 7 | version = "0.3.6" 8 | description = "SCIM2 models serialization and validation with pydantic" 9 | authors = [{name="Yaal Coop", email="contact@yaal.coop"}] 10 | license = {file = "LICENSE"} 11 | readme = "README.md" 12 | keywords = ["scim", "scim2", "provisioning", "pydantic", "rfc7643", "rfc7644"] 13 | classifiers = [ 14 | "Intended Audience :: Developers", 15 | "Development Status :: 3 - Alpha", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Programming Language :: Python :: Implementation :: CPython", 22 | "License :: OSI Approved :: Apache Software License", 23 | "Environment :: Web Environment", 24 | "Programming Language :: Python", 25 | "Operating System :: OS Independent", 26 | ] 27 | 28 | requires-python = ">= 3.9" 29 | dependencies = [ 30 | "pydantic[email]>=2.7.0" 31 | ] 32 | 33 | [project.urls] 34 | documentation = "https://scim2-models.readthedocs.io" 35 | repository = "https://github.com/python-scim/scim2-models" 36 | changelog = "https://scim2-models.readthedocs.io/en/latest/changelog.html" 37 | funding = "https://github.com/sponsors/python-scim" 38 | 39 | [dependency-groups] 40 | dev = [ 41 | "mypy>=1.13.0", 42 | "pytest>=8.2.1", 43 | "pytest-cov>=6.0.0", 44 | "pre-commit-uv>=4.1.4", 45 | "tox-uv>=1.16.0", 46 | ] 47 | doc = [ 48 | "autodoc-pydantic>=2.2.0", 49 | "myst-parser>=3.0.1", 50 | "shibuya>=2024.5.15", 51 | "sphinx-paramlinks>=0.6.0", 52 | "sphinx>=7.3.7", 53 | "sphinx-issues >= 5.0.0", 54 | "sphinx-togglebutton>=0.3.2", 55 | ] 56 | 57 | [tool.coverage.run] 58 | source = [ 59 | "scim2_models", 60 | "tests", 61 | ] 62 | omit = [".tox/*"] 63 | branch = true 64 | 65 | [tool.coverage.report] 66 | exclude_lines = [ 67 | "@pytest.mark.skip", 68 | "pragma: no cover", 69 | "raise NotImplementedError", 70 | "except ImportError", 71 | ] 72 | 73 | [tool.ruff.lint] 74 | select = [ 75 | "B", # flake8-bugbear 76 | "D", # pydocstyle 77 | "E", # pycodestyle 78 | "F", # pyflakes 79 | "I", # isort 80 | "UP", # pyupgrade 81 | ] 82 | ignore = [ 83 | "E501", # line-too-long 84 | "E722", # bare-except 85 | "D100", # public module 86 | "D101", # public class 87 | "D102", # public method 88 | "D103", # public function 89 | "D104", # public package 90 | "D105", # magic method 91 | "D106", # nested class 92 | "D107", # public init 93 | "D203", # no-blank-line-before-class 94 | "D213", # multi-line-summary-second-line 95 | ] 96 | 97 | [tool.ruff.lint.isort] 98 | force-single-line = true 99 | 100 | [tool.ruff.format] 101 | docstring-code-format = true 102 | 103 | [tool.pytest.ini_options] 104 | addopts = "--doctest-modules --doctest-glob='*.rst'" 105 | doctest_optionflags= "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS" 106 | 107 | # [tool.mypy] 108 | # plugins = [ 109 | # "pydantic.mypy" 110 | # ] 111 | 112 | [tool.tox] 113 | requires = ["tox>=4.19"] 114 | env_list = [ 115 | "style", 116 | "py39", 117 | "py310", 118 | "py311", 119 | "py312", 120 | "py313", 121 | "minversions", 122 | "doc", 123 | "coverage", 124 | ] 125 | 126 | [tool.tox.env_run_base] 127 | runner = "uv-venv-lock-runner" 128 | dependency_groups = ["dev"] 129 | commands = [ 130 | ["pytest", "--showlocals", "--full-trace", "{posargs}"], 131 | ] 132 | 133 | [tool.tox.env.style] 134 | skip_install = true 135 | runner = "uv-venv-runner" 136 | commands = [ 137 | ["pre-commit", "run", "--all-files", "--show-diff-on-failure"], 138 | ] 139 | 140 | [tool.tox.env.minversions] 141 | runner = "uv-venv-runner" 142 | uv_resolution = "lowest-direct" 143 | 144 | [tool.tox.env.doc] 145 | dependency_groups = ["doc"] 146 | commands = [ 147 | ["sphinx-build", "--builder", "html", "doc", "build/sphinx/html"], 148 | ["sphinx-build", "--builder", "man", "doc", "build/sphinx/html"], 149 | ] 150 | 151 | [tool.tox.env.coverage] 152 | commands = [ 153 | ["pytest", "--cov", "--cov-fail-under=100", "--cov-report", "term:skip-covered", "{posargs}"], 154 | ["coverage", "html"], 155 | ] 156 | -------------------------------------------------------------------------------- /samples/rfc7643-8.1-user-minimal.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:schemas:core:2.0:User" 4 | ], 5 | "id": "2819c223-7f76-453a-919d-413861904646", 6 | "userName": "bjensen@example.com", 7 | "meta": { 8 | "resourceType": "User", 9 | "created": "2010-01-23T04:56:22Z", 10 | "lastModified": "2011-05-13T04:42:34Z", 11 | "version": "W\\/\"3694e05e9dff590\"", 12 | "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /samples/rfc7643-8.2-user-full.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:schemas:core:2.0:User" 4 | ], 5 | "id": "2819c223-7f76-453a-919d-413861904646", 6 | "externalId": "701984", 7 | "userName": "bjensen@example.com", 8 | "name": { 9 | "formatted": "Ms. Barbara J Jensen, III", 10 | "familyName": "Jensen", 11 | "givenName": "Barbara", 12 | "middleName": "Jane", 13 | "honorificPrefix": "Ms.", 14 | "honorificSuffix": "III" 15 | }, 16 | "displayName": "Babs Jensen", 17 | "nickName": "Babs", 18 | "profileUrl": "https://login.example.com/bjensen", 19 | "emails": [ 20 | { 21 | "value": "bjensen@example.com", 22 | "type": "work", 23 | "primary": true 24 | }, 25 | { 26 | "value": "babs@jensen.org", 27 | "type": "home" 28 | } 29 | ], 30 | "addresses": [ 31 | { 32 | "type": "work", 33 | "streetAddress": "100 Universal City Plaza", 34 | "locality": "Hollywood", 35 | "region": "CA", 36 | "postalCode": "91608", 37 | "country": "USA", 38 | "formatted": "100 Universal City Plaza\nHollywood, CA 91608 USA", 39 | "primary": true 40 | }, 41 | { 42 | "type": "home", 43 | "streetAddress": "456 Hollywood Blvd", 44 | "locality": "Hollywood", 45 | "region": "CA", 46 | "postalCode": "91608", 47 | "country": "USA", 48 | "formatted": "456 Hollywood Blvd\nHollywood, CA 91608 USA" 49 | } 50 | ], 51 | "phoneNumbers": [ 52 | { 53 | "value": "555-555-5555", 54 | "type": "work" 55 | }, 56 | { 57 | "value": "555-555-4444", 58 | "type": "mobile" 59 | } 60 | ], 61 | "ims": [ 62 | { 63 | "value": "someaimhandle", 64 | "type": "aim" 65 | } 66 | ], 67 | "photos": [ 68 | { 69 | "value": "https://photos.example.com/profilephoto/72930000000Ccne/F", 70 | "type": "photo" 71 | }, 72 | { 73 | "value": "https://photos.example.com/profilephoto/72930000000Ccne/T", 74 | "type": "thumbnail" 75 | } 76 | ], 77 | "userType": "Employee", 78 | "title": "Tour Guide", 79 | "preferredLanguage": "en-US", 80 | "locale": "en-US", 81 | "timezone": "America/Los_Angeles", 82 | "active": true, 83 | "password": "t1meMa$heen", 84 | "groups": [ 85 | { 86 | "value": "e9e30dba-f08f-4109-8486-d5c6a331660a", 87 | "$ref": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", 88 | "display": "Tour Guides" 89 | }, 90 | { 91 | "value": "fc348aa8-3835-40eb-a20b-c726e15c55b5", 92 | "$ref": "https://example.com/v2/Groups/fc348aa8-3835-40eb-a20b-c726e15c55b5", 93 | "display": "Employees" 94 | }, 95 | { 96 | "value": "71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7", 97 | "$ref": "https://example.com/v2/Groups/71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7", 98 | "display": "US Employees" 99 | } 100 | ], 101 | "x509Certificates": [ 102 | { 103 | "value": "MIIDQzCCAqygAwIBAgICEAAwDQYJKoZIhvcNAQEFBQAwTjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFDASBgNVBAoMC2V4YW1wbGUuY29tMRQwEgYDVQQDDAtleGFtcGxlLmNvbTAeFw0xMTEwMjIwNjI0MzFaFw0xMjEwMDQwNjI0MzFaMH8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRQwEgYDVQQKDAtleGFtcGxlLmNvbTEhMB8GA1UEAwwYTXMuIEJhcmJhcmEgSiBKZW5zZW4gSUlJMSIwIAYJKoZIhvcNAQkBFhNiamVuc2VuQGV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7Kr+Dcds/JQ5GwejJFcBIP682X3xpjis56AK02bc1FLgzdLI8auoR+cC9/Vrh5t66HkQIOdA4unHh0AaZ4xL5PhVbXIPMB5vAPKpzz5iPSi8xO8SL7I7SDhcBVJhqVqr3HgllEG6UClDdHO7nkLuwXq8HcISKkbT5WFTVfFZzidPl8HZ7DhXkZIRtJwBweq4bvm3hM1Os7UQH05ZS6cVDgweKNwdLLrT51ikSQG3DYrl+ft781UQRIqxgwqCfXEuDiinPh0kkvIi5jivVu1Z9QiwlYEdRbLJ4zJQBmDrSGTMYn4lRc2HgHO4DqB/bnMVorHB0CC6AV1QoFK4GPe1LwIDAQABo3sweTAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU8pD0U0vsZIsaA16lL8En8bx0F/gwHwYDVR0jBBgwFoAUdGeKitcaF7gnzsNwDx708kqaVt0wDQYJKoZIhvcNAQEFBQADgYEAA81SsFnOdYJtNg5Tcq+/ByEDrBgnusx0jloUhByPMEVkoMZ3J7j1ZgI8rAbOkNngX8+pKfTiDz1RC4+dx8oU6Za+4NJXUjlL5CvV6BEYb1+QAEJwitTVvxB/A67g42/vzgAtoRUeDov1+GFiBZ+GNF/cAYKcMtGcrs2i97ZkJMo=" 104 | } 105 | ], 106 | "meta": { 107 | "resourceType": "User", 108 | "created": "2010-01-23T04:56:22Z", 109 | "lastModified": "2011-05-13T04:42:34Z", 110 | "version": "W\\/\"a330bc54f0671c9\"", 111 | "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /samples/rfc7643-8.3-enterprise_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:schemas:core:2.0:User", 4 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" 5 | ], 6 | "id": "2819c223-7f76-453a-919d-413861904646", 7 | "externalId": "701984", 8 | "userName": "bjensen@example.com", 9 | "name": { 10 | "formatted": "Ms. Barbara J Jensen, III", 11 | "familyName": "Jensen", 12 | "givenName": "Barbara", 13 | "middleName": "Jane", 14 | "honorificPrefix": "Ms.", 15 | "honorificSuffix": "III" 16 | }, 17 | "displayName": "Babs Jensen", 18 | "nickName": "Babs", 19 | "profileUrl": "https://login.example.com/bjensen", 20 | "emails": [ 21 | { 22 | "value": "bjensen@example.com", 23 | "type": "work", 24 | "primary": true 25 | }, 26 | { 27 | "value": "babs@jensen.org", 28 | "type": "home" 29 | } 30 | ], 31 | "addresses": [ 32 | { 33 | "streetAddress": "100 Universal City Plaza", 34 | "locality": "Hollywood", 35 | "region": "CA", 36 | "postalCode": "91608", 37 | "country": "USA", 38 | "formatted": "100 Universal City Plaza\nHollywood, CA 91608 USA", 39 | "type": "work", 40 | "primary": true 41 | }, 42 | { 43 | "streetAddress": "456 Hollywood Blvd", 44 | "locality": "Hollywood", 45 | "region": "CA", 46 | "postalCode": "91608", 47 | "country": "USA", 48 | "formatted": "456 Hollywood Blvd\nHollywood, CA 91608 USA", 49 | "type": "home" 50 | } 51 | ], 52 | "phoneNumbers": [ 53 | { 54 | "value": "555-555-5555", 55 | "type": "work" 56 | }, 57 | { 58 | "value": "555-555-4444", 59 | "type": "mobile" 60 | } 61 | ], 62 | "ims": [ 63 | { 64 | "value": "someaimhandle", 65 | "type": "aim" 66 | } 67 | ], 68 | "photos": [ 69 | { 70 | "value": "https://photos.example.com/profilephoto/72930000000Ccne/F", 71 | "type": "photo" 72 | }, 73 | { 74 | "value": "https://photos.example.com/profilephoto/72930000000Ccne/T", 75 | "type": "thumbnail" 76 | } 77 | ], 78 | "userType": "Employee", 79 | "title": "Tour Guide", 80 | "preferredLanguage": "en-US", 81 | "locale": "en-US", 82 | "timezone": "America/Los_Angeles", 83 | "active": true, 84 | "password": "t1meMa$heen", 85 | "groups": [ 86 | { 87 | "value": "e9e30dba-f08f-4109-8486-d5c6a331660a", 88 | "$ref": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", 89 | "display": "Tour Guides" 90 | }, 91 | { 92 | "value": "fc348aa8-3835-40eb-a20b-c726e15c55b5", 93 | "$ref": "https://example.com/v2/Groups/fc348aa8-3835-40eb-a20b-c726e15c55b5", 94 | "display": "Employees" 95 | }, 96 | { 97 | "value": "71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7", 98 | "$ref": "https://example.com/v2/Groups/71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7", 99 | "display": "US Employees" 100 | } 101 | ], 102 | "x509Certificates": [ 103 | { 104 | "value": "MIIDQzCCAqygAwIBAgICEAAwDQYJKoZIhvcNAQEFBQAwTjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFDASBgNVBAoMC2V4YW1wbGUuY29tMRQwEgYDVQQDDAtleGFtcGxlLmNvbTAeFw0xMTEwMjIwNjI0MzFaFw0xMjEwMDQwNjI0MzFaMH8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRQwEgYDVQQKDAtleGFtcGxlLmNvbTEhMB8GA1UEAwwYTXMuIEJhcmJhcmEgSiBKZW5zZW4gSUlJMSIwIAYJKoZIhvcNAQkBFhNiamVuc2VuQGV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7Kr+Dcds/JQ5GwejJFcBIP682X3xpjis56AK02bc1FLgzdLI8auoR+cC9/Vrh5t66HkQIOdA4unHh0AaZ4xL5PhVbXIPMB5vAPKpzz5iPSi8xO8SL7I7SDhcBVJhqVqr3HgllEG6UClDdHO7nkLuwXq8HcISKkbT5WFTVfFZzidPl8HZ7DhXkZIRtJwBweq4bvm3hM1Os7UQH05ZS6cVDgweKNwdLLrT51ikSQG3DYrl+ft781UQRIqxgwqCfXEuDiinPh0kkvIi5jivVu1Z9QiwlYEdRbLJ4zJQBmDrSGTMYn4lRc2HgHO4DqB/bnMVorHB0CC6AV1QoFK4GPe1LwIDAQABo3sweTAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU8pD0U0vsZIsaA16lL8En8bx0F/gwHwYDVR0jBBgwFoAUdGeKitcaF7gnzsNwDx708kqaVt0wDQYJKoZIhvcNAQEFBQADgYEAA81SsFnOdYJtNg5Tcq+/ByEDrBgnusx0jloUhByPMEVkoMZ3J7j1ZgI8rAbOkNngX8+pKfTiDz1RC4+dx8oU6Za+4NJXUjlL5CvV6BEYb1+QAEJwitTVvxB/A67g42/vzgAtoRUeDov1+GFiBZ+GNF/cAYKcMtGcrs2i97ZkJMo=" 105 | } 106 | ], 107 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { 108 | "employeeNumber": "701984", 109 | "costCenter": "4130", 110 | "organization": "Universal Studios", 111 | "division": "Theme Park", 112 | "department": "Tour Operations", 113 | "manager": { 114 | "value": "26118915-6090-4610-87e4-49d8ca9f808d", 115 | "$ref": "https://example.com/v2/Users/26118915-6090-4610-87e4-49d8ca9f808d", 116 | "displayName": "John Smith" 117 | } 118 | }, 119 | "meta": { 120 | "resourceType": "User", 121 | "created": "2010-01-23T04:56:22Z", 122 | "lastModified": "2011-05-13T04:42:34Z", 123 | "version": "W\\/\"3694e05e9dff591\"", 124 | "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /samples/rfc7643-8.4-group.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:schemas:core:2.0:Group" 4 | ], 5 | "id": "e9e30dba-f08f-4109-8486-d5c6a331660a", 6 | "displayName": "Tour Guides", 7 | "members": [ 8 | { 9 | "value": "2819c223-7f76-453a-919d-413861904646", 10 | "$ref": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", 11 | "display": "Babs Jensen" 12 | }, 13 | { 14 | "value": "902c246b-6245-4190-8e05-00816be7344a", 15 | "$ref": "https://example.com/v2/Users/902c246b-6245-4190-8e05-00816be7344a", 16 | "display": "Mandy Pepperidge" 17 | } 18 | ], 19 | "meta": { 20 | "resourceType": "Group", 21 | "created": "2010-01-23T04:56:22Z", 22 | "lastModified": "2011-05-13T04:42:34Z", 23 | "version": "W\\/\"3694e05e9dff592\"", 24 | "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /samples/rfc7643-8.5-service_provider_configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" 4 | ], 5 | "documentationUri": "http://example.com/help/scim.html", 6 | "patch": { 7 | "supported": true 8 | }, 9 | "bulk": { 10 | "supported": true, 11 | "maxOperations": 1000, 12 | "maxPayloadSize": 1048576 13 | }, 14 | "filter": { 15 | "supported": true, 16 | "maxResults": 200 17 | }, 18 | "changePassword": { 19 | "supported": true 20 | }, 21 | "sort": { 22 | "supported": true 23 | }, 24 | "etag": { 25 | "supported": true 26 | }, 27 | "authenticationSchemes": [ 28 | { 29 | "name": "OAuth Bearer Token", 30 | "description": "Authentication scheme using the OAuth Bearer Token Standard", 31 | "specUri": "http://www.rfc-editor.org/info/rfc6750", 32 | "documentationUri": "http://example.com/help/oauth.html", 33 | "type": "oauthbearertoken", 34 | "primary": true 35 | }, 36 | { 37 | "name": "HTTP Basic", 38 | "description": "Authentication scheme using the HTTP Basic Standard", 39 | "specUri": "http://www.rfc-editor.org/info/rfc2617", 40 | "documentationUri": "http://example.com/help/httpBasic.html", 41 | "type": "httpbasic" 42 | } 43 | ], 44 | "meta": { 45 | "location": "https://example.com/v2/ServiceProviderConfig", 46 | "resourceType": "ServiceProviderConfig", 47 | "created": "2010-01-23T04:56:22Z", 48 | "lastModified": "2011-05-13T04:42:34Z", 49 | "version": "W\\/\"3694e05e9dff594\"" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /samples/rfc7643-8.6-resource_type-group.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:schemas:core:2.0:ResourceType" 4 | ], 5 | "id": "Group", 6 | "name": "Group", 7 | "endpoint": "/Groups", 8 | "description": "Group", 9 | "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", 10 | "meta": { 11 | "location": "https://example.com/v2/ResourceTypes/Group", 12 | "resourceType": "ResourceType" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /samples/rfc7643-8.6-resource_type-user.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:schemas:core:2.0:ResourceType" 4 | ], 5 | "id": "User", 6 | "name": "User", 7 | "endpoint": "/Users", 8 | "description": "User Account", 9 | "schema": "urn:ietf:params:scim:schemas:core:2.0:User", 10 | "schemaExtensions": [ 11 | { 12 | "schema": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", 13 | "required": true 14 | } 15 | ], 16 | "meta": { 17 | "location": "https://example.com/v2/ResourceTypes/User", 18 | "resourceType": "ResourceType" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /samples/rfc7643-8.7.1-schema-enterprise_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], 3 | "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", 4 | "name": "EnterpriseUser", 5 | "description": "Enterprise User", 6 | "attributes": [ 7 | { 8 | "name": "employeeNumber", 9 | "type": "string", 10 | "multiValued": false, 11 | "description": "Numeric or alphanumeric identifier assigned to a person, typically based on order of hire or association with an organization.", 12 | "required": false, 13 | "caseExact": false, 14 | "mutability": "readWrite", 15 | "returned": "default", 16 | "uniqueness": "none" 17 | }, 18 | { 19 | "name": "costCenter", 20 | "type": "string", 21 | "multiValued": false, 22 | "description": "Identifies the name of a cost center.", 23 | "required": false, 24 | "caseExact": false, 25 | "mutability": "readWrite", 26 | "returned": "default", 27 | "uniqueness": "none" 28 | }, 29 | { 30 | "name": "organization", 31 | "type": "string", 32 | "multiValued": false, 33 | "description": "Identifies the name of an organization.", 34 | "required": false, 35 | "caseExact": false, 36 | "mutability": "readWrite", 37 | "returned": "default", 38 | "uniqueness": "none" 39 | }, 40 | { 41 | "name": "division", 42 | "type": "string", 43 | "multiValued": false, 44 | "description": "Identifies the name of a division.", 45 | "required": false, 46 | "caseExact": false, 47 | "mutability": "readWrite", 48 | "returned": "default", 49 | "uniqueness": "none" 50 | }, 51 | { 52 | "name": "department", 53 | "type": "string", 54 | "multiValued": false, 55 | "description": "Identifies the name of a department.", 56 | "required": false, 57 | "caseExact": false, 58 | "mutability": "readWrite", 59 | "returned": "default", 60 | "uniqueness": "none" 61 | }, 62 | { 63 | "name": "manager", 64 | "type": "complex", 65 | "multiValued": false, 66 | "description": "The User's manager. A complex type that optionally allows service providers to represent organizational hierarchy by referencing the 'id' attribute of another User.", 67 | "required": false, 68 | "subAttributes": [ 69 | { 70 | "name": "value", 71 | "type": "string", 72 | "multiValued": false, 73 | "description": "The id of the SCIM resource representing the User's manager. REQUIRED.", 74 | "required": true, 75 | "caseExact": false, 76 | "mutability": "readWrite", 77 | "returned": "default", 78 | "uniqueness": "none" 79 | }, 80 | { 81 | "name": "$ref", 82 | "type": "reference", 83 | "referenceTypes": [ 84 | "User" 85 | ], 86 | "multiValued": false, 87 | "description": "The URI of the SCIM resource representing the User's manager. REQUIRED.", 88 | "required": true, 89 | "caseExact": false, 90 | "mutability": "readWrite", 91 | "returned": "default", 92 | "uniqueness": "none" 93 | }, 94 | { 95 | "name": "displayName", 96 | "type": "string", 97 | "multiValued": false, 98 | "description": "The displayName of the User's manager. OPTIONAL and READ-ONLY.", 99 | "required": false, 100 | "caseExact": false, 101 | "mutability": "readOnly", 102 | "returned": "default", 103 | "uniqueness": "none" 104 | } 105 | ], 106 | "mutability": "readWrite", 107 | "returned": "default" 108 | } 109 | ], 110 | "meta": { 111 | "resourceType": "Schema", 112 | "location": "/v2/Schemas/urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /samples/rfc7643-8.7.1-schema-group.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], 3 | "id": "urn:ietf:params:scim:schemas:core:2.0:Group", 4 | "name": "Group", 5 | "description": "Group", 6 | "attributes": [ 7 | { 8 | "name": "displayName", 9 | "type": "string", 10 | "multiValued": false, 11 | "description": "A human-readable name for the Group. REQUIRED.", 12 | "required": false, 13 | "caseExact": false, 14 | "mutability": "readWrite", 15 | "returned": "default", 16 | "uniqueness": "none" 17 | }, 18 | { 19 | "name": "members", 20 | "type": "complex", 21 | "multiValued": true, 22 | "description": "A list of members of the Group.", 23 | "required": false, 24 | "subAttributes": [ 25 | { 26 | "name": "value", 27 | "type": "string", 28 | "multiValued": false, 29 | "description": "Identifier of the member of this Group.", 30 | "required": false, 31 | "caseExact": false, 32 | "mutability": "immutable", 33 | "returned": "default", 34 | "uniqueness": "none" 35 | }, 36 | { 37 | "name": "$ref", 38 | "type": "reference", 39 | "referenceTypes": [ 40 | "User", 41 | "Group" 42 | ], 43 | "multiValued": false, 44 | "description": "The URI corresponding to a SCIM resource that is a member of this Group.", 45 | "required": false, 46 | "caseExact": false, 47 | "mutability": "immutable", 48 | "returned": "default", 49 | "uniqueness": "none" 50 | }, 51 | { 52 | "name": "type", 53 | "type": "string", 54 | "multiValued": false, 55 | "description": "A label indicating the type of resource, e.g., 'User' or 'Group'.", 56 | "required": false, 57 | "caseExact": false, 58 | "canonicalValues": [ 59 | "User", 60 | "Group" 61 | ], 62 | "mutability": "immutable", 63 | "returned": "default", 64 | "uniqueness": "none" 65 | }, 66 | { 67 | "name": "display", 68 | "type": "string", 69 | "multiValued": false, 70 | "description": "A human-readable name for the group member, primarily used for display purposes.", 71 | "required": false, 72 | "caseExact": false, 73 | "mutability": "readOnly", 74 | "returned": "default", 75 | "uniqueness": "none" 76 | } 77 | ], 78 | "mutability": "readWrite", 79 | "returned": "default" 80 | } 81 | ], 82 | "meta": { 83 | "resourceType": "Schema", 84 | "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /samples/rfc7643-8.7.2-schema-resource_type.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], 3 | "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", 4 | "name": "ResourceType", 5 | "description": "Specifies the schema that describes a SCIM resource type", 6 | "attributes": [ 7 | { 8 | "name": "id", 9 | "type": "string", 10 | "multiValued": false, 11 | "description": "The resource type's server unique id. May be the same as the 'name' attribute.", 12 | "required": false, 13 | "caseExact": false, 14 | "mutability": "readOnly", 15 | "returned": "default", 16 | "uniqueness": "none" 17 | }, 18 | { 19 | "name": "name", 20 | "type": "string", 21 | "multiValued": false, 22 | "description": "The resource type name. When applicable, service providers MUST specify the name, e.g., 'User'.", 23 | "required": true, 24 | "caseExact": false, 25 | "mutability": "readOnly", 26 | "returned": "default", 27 | "uniqueness": "none" 28 | }, 29 | { 30 | "name": "description", 31 | "type": "string", 32 | "multiValued": false, 33 | "description": "The resource type's human-readable description. When applicable, service providers MUST specify the description.", 34 | "required": false, 35 | "caseExact": false, 36 | "mutability": "readOnly", 37 | "returned": "default", 38 | "uniqueness": "none" 39 | }, 40 | { 41 | "name": "endpoint", 42 | "type": "reference", 43 | "referenceTypes": [ 44 | "uri" 45 | ], 46 | "multiValued": false, 47 | "description": "The resource type's HTTP-addressable endpoint relative to the Base URL, e.g., '/Users'.", 48 | "required": true, 49 | "caseExact": false, 50 | "mutability": "readOnly", 51 | "returned": "default", 52 | "uniqueness": "none" 53 | }, 54 | { 55 | "name": "schema", 56 | "type": "reference", 57 | "referenceTypes": [ 58 | "uri" 59 | ], 60 | "multiValued": false, 61 | "description": "The resource type's primary/base schema URI.", 62 | "required": true, 63 | "caseExact": true, 64 | "mutability": "readOnly", 65 | "returned": "default", 66 | "uniqueness": "none" 67 | }, 68 | { 69 | "name": "schemaExtensions", 70 | "type": "complex", 71 | "multiValued": true, 72 | "description": "A list of URIs of the resource type's schema extensions.", 73 | "required": true, 74 | "mutability": "readOnly", 75 | "returned": "default", 76 | "subAttributes": [ 77 | { 78 | "name": "schema", 79 | "type": "reference", 80 | "referenceTypes": [ 81 | "uri" 82 | ], 83 | "multiValued": false, 84 | "description": "The URI of a schema extension.", 85 | "required": true, 86 | "caseExact": true, 87 | "mutability": "readOnly", 88 | "returned": "default", 89 | "uniqueness": "none" 90 | }, 91 | { 92 | "name": "required", 93 | "type": "boolean", 94 | "multiValued": false, 95 | "description": "A Boolean value that specifies whether or not the schema extension is required for the resource type. If True, a resource of this type MUST include this schema extension and also include any attributes declared as required in this schema extension. If False, a resource of this type MAY omit this schema extension.", 96 | "required": true, 97 | "mutability": "readOnly", 98 | "returned": "default" 99 | } 100 | ] 101 | } 102 | ] 103 | } 104 | -------------------------------------------------------------------------------- /samples/rfc7643-8.7.2-schema-service_provider_configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], 3 | "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", 4 | "name": "Service Provider Configuration", 5 | "description": "Schema for representing the service provider's configuration", 6 | "attributes": [ 7 | { 8 | "name": "documentationUri", 9 | "type": "reference", 10 | "referenceTypes": [ 11 | "external" 12 | ], 13 | "multiValued": false, 14 | "description": "An HTTP-addressable URL pointing to the service provider's human-consumable help documentation.", 15 | "required": false, 16 | "caseExact": false, 17 | "mutability": "readOnly", 18 | "returned": "default", 19 | "uniqueness": "none" 20 | }, 21 | { 22 | "name": "patch", 23 | "type": "complex", 24 | "multiValued": false, 25 | "description": "A complex type that specifies PATCH configuration options.", 26 | "required": true, 27 | "returned": "default", 28 | "mutability": "readOnly", 29 | "subAttributes": [ 30 | { 31 | "name": "supported", 32 | "type": "boolean", 33 | "multiValued": false, 34 | "description": "A Boolean value specifying whether or not the operation is supported.", 35 | "required": true, 36 | "mutability": "readOnly", 37 | "returned": "default" 38 | } 39 | ] 40 | }, 41 | { 42 | "name": "bulk", 43 | "type": "complex", 44 | "multiValued": false, 45 | "description": "A complex type that specifies bulk configuration options.", 46 | "required": true, 47 | "returned": "default", 48 | "mutability": "readOnly", 49 | "subAttributes": [ 50 | { 51 | "name": "supported", 52 | "type": "boolean", 53 | "multiValued": false, 54 | "description": "A Boolean value specifying whether or not the operation is supported.", 55 | "required": true, 56 | "mutability": "readOnly", 57 | "returned": "default" 58 | }, 59 | { 60 | "name": "maxOperations", 61 | "type": "integer", 62 | "multiValued": false, 63 | "description": "An integer value specifying the maximum number of operations.", 64 | "required": true, 65 | "mutability": "readOnly", 66 | "returned": "default", 67 | "uniqueness": "none" 68 | }, 69 | { 70 | "name": "maxPayloadSize", 71 | "type": "integer", 72 | "multiValued": false, 73 | "description": "An integer value specifying the maximum payload size in bytes.", 74 | "required": true, 75 | "mutability": "readOnly", 76 | "returned": "default", 77 | "uniqueness": "none" 78 | } 79 | ] 80 | }, 81 | { 82 | "name": "filter", 83 | "type": "complex", 84 | "multiValued": false, 85 | "description": "A complex type that specifies FILTER options.", 86 | "required": true, 87 | "returned": "default", 88 | "mutability": "readOnly", 89 | "subAttributes": [ 90 | { 91 | "name": "supported", 92 | "type": "boolean", 93 | "multiValued": false, 94 | "description": "A Boolean value specifying whether or not the operation is supported.", 95 | "required": true, 96 | "mutability": "readOnly", 97 | "returned": "default" 98 | }, 99 | { 100 | "name": "maxResults", 101 | "type": "integer", 102 | "multiValued": false, 103 | "description": "An integer value specifying the maximum number of resources returned in a response.", 104 | "required": true, 105 | "mutability": "readOnly", 106 | "returned": "default", 107 | "uniqueness": "none" 108 | } 109 | ] 110 | }, 111 | { 112 | "name": "changePassword", 113 | "type": "complex", 114 | "multiValued": false, 115 | "description": "A complex type that specifies configuration options related to changing a password.", 116 | "required": true, 117 | "returned": "default", 118 | "mutability": "readOnly", 119 | "subAttributes": [ 120 | { 121 | "name": "supported", 122 | "type": "boolean", 123 | "multiValued": false, 124 | "description": "A Boolean value specifying whether or not the operation is supported.", 125 | "required": true, 126 | "mutability": "readOnly", 127 | "returned": "default" 128 | } 129 | ] 130 | }, 131 | { 132 | "name": "sort", 133 | "type": "complex", 134 | "multiValued": false, 135 | "description": "A complex type that specifies sort result options.", 136 | "required": true, 137 | "returned": "default", 138 | "mutability": "readOnly", 139 | "subAttributes": [ 140 | { 141 | "name": "supported", 142 | "type": "boolean", 143 | "multiValued": false, 144 | "description": "A Boolean value specifying whether or not the operation is supported.", 145 | "required": true, 146 | "mutability": "readOnly", 147 | "returned": "default" 148 | } 149 | ] 150 | }, 151 | { 152 | "name": "etag", 153 | "type": "complex", 154 | "multiValued": false, 155 | "description": "A complex type that specifies ETag result options.", 156 | "required": true, 157 | "returned": "default", 158 | "mutability": "readOnly", 159 | "subAttributes": [ 160 | { 161 | "name": "supported", 162 | "type": "boolean", 163 | "multiValued": false, 164 | "description": "A Boolean value specifying whether or not the operation is supported.", 165 | "required": true, 166 | "mutability": "readOnly", 167 | "returned": "default" 168 | } 169 | ] 170 | }, 171 | { 172 | "name": "authenticationSchemes", 173 | "type": "complex", 174 | "multiValued": true, 175 | "description": "A complex type that specifies supported authentication scheme properties.", 176 | "required": true, 177 | "returned": "default", 178 | "mutability": "readOnly", 179 | "subAttributes": [ 180 | { 181 | "name": "name", 182 | "type": "string", 183 | "multiValued": false, 184 | "description": "The common authentication scheme name, e.g., HTTP Basic.", 185 | "required": true, 186 | "caseExact": false, 187 | "mutability": "readOnly", 188 | "returned": "default", 189 | "uniqueness": "none" 190 | }, 191 | { 192 | "name": "description", 193 | "type": "string", 194 | "multiValued": false, 195 | "description": "A description of the authentication scheme.", 196 | "required": true, 197 | "caseExact": false, 198 | "mutability": "readOnly", 199 | "returned": "default", 200 | "uniqueness": "none" 201 | }, 202 | { 203 | "name": "specUri", 204 | "type": "reference", 205 | "referenceTypes": [ 206 | "external" 207 | ], 208 | "multiValued": false, 209 | "description": "An HTTP-addressable URL pointing to the authentication scheme's specification.", 210 | "required": false, 211 | "caseExact": false, 212 | "mutability": "readOnly", 213 | "returned": "default", 214 | "uniqueness": "none" 215 | }, 216 | { 217 | "name": "documentationUri", 218 | "type": "reference", 219 | "referenceTypes": [ 220 | "external" 221 | ], 222 | "multiValued": false, 223 | "description": "An HTTP-addressable URL pointing to the authentication scheme's usage documentation.", 224 | "required": false, 225 | "caseExact": false, 226 | "mutability": "readOnly", 227 | "returned": "default", 228 | "uniqueness": "none" 229 | } 230 | ] 231 | } 232 | ] 233 | } 234 | -------------------------------------------------------------------------------- /samples/rfc7644-3.12-error-bad_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:Error" 4 | ], 5 | "scimType": "mutability", 6 | "detail": "Attribute 'id' is readOnly", 7 | "status": "400" 8 | } 9 | -------------------------------------------------------------------------------- /samples/rfc7644-3.12-error-not_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], 3 | "detail":"Resource 2819c223-7f76-453a-919d-413861904646 not found", 4 | "status": "404" 5 | } 6 | -------------------------------------------------------------------------------- /samples/rfc7644-3.14-user-post_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "userName":"bjensen", 4 | "externalId":"bjensen", 5 | "name":{ 6 | "formatted":"Ms. Barbara J Jensen III", 7 | "familyName":"Jensen", 8 | "givenName":"Barbara" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /samples/rfc7644-3.14-user-post_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "id":"2819c223-7f76-453a-919d-413861904646", 4 | "meta":{ 5 | "resourceType":"User", 6 | "created":"2011-08-01T21:32:44.882000Z", 7 | "lastModified":"2011-08-01T21:32:44.882000Z", 8 | "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", 9 | "version":"W\/\"e180ee84f0671b1\"" 10 | }, 11 | "name":{ 12 | "formatted":"Ms. Barbara J Jensen III", 13 | "familyName":"Jensen", 14 | "givenName":"Barbara" 15 | }, 16 | "userName":"bjensen" 17 | } 18 | -------------------------------------------------------------------------------- /samples/rfc7644-3.3-user-post_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "userName":"bjensen", 4 | "externalId":"bjensen", 5 | "name":{ 6 | "formatted":"Ms. Barbara J Jensen III", 7 | "familyName":"Jensen", 8 | "givenName":"Barbara" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /samples/rfc7644-3.3-user-post_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "id":"2819c223-7f76-453a-919d-413861904646", 4 | "externalId":"bjensen", 5 | "meta":{ 6 | "resourceType":"User", 7 | "created":"2011-08-01T21:32:44.882000Z", 8 | "lastModified":"2011-08-01T21:32:44.882000Z", 9 | "location": 10 | "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", 11 | "version":"W\/\"e180ee84f0671b1\"" 12 | }, 13 | "name":{ 14 | "formatted":"Ms. Barbara J Jensen III", 15 | "familyName":"Jensen", 16 | "givenName":"Barbara" 17 | }, 18 | "userName":"bjensen" 19 | } 20 | -------------------------------------------------------------------------------- /samples/rfc7644-3.4.1-user-known-resource.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "id":"2819c223-7f76-453a-919d-413861904646", 4 | "externalId":"bjensen", 5 | "meta":{ 6 | "resourceType":"User", 7 | "created":"2011-08-01T18:29:49.793000Z", 8 | "lastModified":"2011-08-01T18:29:49.793000Z", 9 | "location": 10 | "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", 11 | "version":"W\/\"f250dd84f0671c3\"" 12 | }, 13 | "name":{ 14 | "formatted":"Ms. Barbara J Jensen III", 15 | "familyName":"Jensen", 16 | "givenName":"Barbara" 17 | }, 18 | "userName":"bjensen", 19 | "phoneNumbers":[ 20 | { 21 | "value":"555-555-8377", 22 | "type":"work" 23 | } 24 | ], 25 | "emails":[ 26 | { 27 | "value":"bjensen@example.com", 28 | "type":"work" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /samples/rfc7644-3.4.2-list_response-partial_attributes.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 3 | "totalResults":2, 4 | "Resources":[ 5 | { 6 | "id":"2819c223-7f76-453a-919d-413861904646", 7 | "userName":"bjensen" 8 | }, 9 | { 10 | "id":"c75ad752-64ae-4823-840d-ffa80929976c", 11 | "userName":"jsmith" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /samples/rfc7644-3.4.3-list_response-post_query.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:ListResponse" 4 | ], 5 | "totalResults": 100, 6 | "itemsPerPage": 10, 7 | "startIndex": 1, 8 | "Resources": [ 9 | { 10 | "id": "2819c223-7f76-413861904646", 11 | "userName": "jsmith", 12 | "displayName": "Smith, James" 13 | }, 14 | { 15 | "id": "c8596b90-7539-4f20968d1908", 16 | "displayName": "Smith Family" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /samples/rfc7644-3.4.3-search_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:SearchRequest" 4 | ], 5 | "attributes": [ 6 | "displayName", 7 | "userName" 8 | ], 9 | "filter": "displayName sw \"smith\"", 10 | "startIndex": 1, 11 | "count": 10 12 | } 13 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.1-user-put_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "id":"2819c223-7f76-453a-919d-413861904646", 4 | "userName":"bjensen", 5 | "externalId":"bjensen", 6 | "name":{ 7 | "formatted":"Ms. Barbara J Jensen III", 8 | "familyName":"Jensen", 9 | "givenName":"Barbara", 10 | "middleName":"Jane" 11 | }, 12 | "roles":[], 13 | "emails":[ 14 | { 15 | "value":"bjensen@example.com" 16 | }, 17 | { 18 | "value":"babs@jensen.org" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.1-user-put_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "id":"2819c223-7f76-453a-919d-413861904646", 4 | "userName":"bjensen", 5 | "externalId":"bjensen", 6 | "name":{ 7 | "formatted":"Ms. Barbara J Jensen III", 8 | "familyName":"Jensen", 9 | "givenName":"Barbara", 10 | "middleName":"Jane" 11 | }, 12 | "emails":[ 13 | { 14 | "value":"bjensen@example.com" 15 | }, 16 | { 17 | "value":"babs@jensen.org" 18 | } 19 | ], 20 | "meta": { 21 | "resourceType":"User", 22 | "created":"2011-08-08T04:56:22Z", 23 | "lastModified":"2011-08-08T08:00:12Z", 24 | "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", 25 | "version":"W\/\"b431af54f0671a2\"" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.1-patch_op-add_emails.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "add", 8 | "value": { 9 | "emails": [ 10 | { 11 | "value": "babs@jensen.org", 12 | "type": "home" 13 | } 14 | ], 15 | "nickname": "Babs" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.1-patch_op-add_members.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "add", 8 | "path": "members", 9 | "value": [ 10 | { 11 | "display": "Babs Jensen", 12 | "$ref": "https://example.com/v2/Users/2819c223...413861904646", 13 | "value": "2819c223-7f76-453a-919d-413861904646" 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.2-patch_op-remove_all_members.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "remove", 8 | "path": "members" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.2-patch_op-remove_and_add_one_member.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "remove", 8 | "path": "members[value eq\"2819c223...919d-413861904646\"]" 9 | }, 10 | { 11 | "op": "add", 12 | "path": "members", 13 | "value": [ 14 | { 15 | "display": "James Smith", 16 | "$ref": "https://example.com/v2/Users/08e1d05d...473d93df9210", 17 | "value": "08e1d05d...473d93df9210" 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.2-patch_op-remove_multi_complex_value.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "remove", 8 | "path": "emails[type eq \"work\" and value ew \"example.com\"]" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.2-patch_op-remove_one_member.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "remove", 8 | "path": "members[value eq \"2819c223-7f76-...413861904646\"]" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.3-patch_op-replace_all_email_values.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "replace", 8 | "value": { 9 | "emails": [ 10 | { 11 | "value": "bjensen@example.com", 12 | "type": "work", 13 | "primary": true 14 | }, 15 | { 16 | "value": "babs@jensen.org", 17 | "type": "home" 18 | } 19 | ], 20 | "nickname": "Babs" 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.3-patch_op-replace_all_members.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "remove", 8 | "path": "members" 9 | }, 10 | { 11 | "op": "add", 12 | "path": "members", 13 | "value": [ 14 | { 15 | "display": "Babs Jensen", 16 | "$ref": "https://example.com/v2/Users/2819c223...413861904646", 17 | "value": "2819c223-7f76-453a-919d-413861904646" 18 | }, 19 | { 20 | "display": "James Smith", 21 | "$ref": "https://example.com/v2/Users/08e1d05d...473d93df9210", 22 | "value": "08e1d05d-121c-4561-8b96-473d93df9210" 23 | } 24 | ] 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.3-patch_op-replace_street_address.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "replace", 8 | "path": "addresses[type eq \"work\"].streetAddress", 9 | "value": "1010 Broadway Ave" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /samples/rfc7644-3.5.2.3-patch_op-replace_user_work_address.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 4 | ], 5 | "Operations": [ 6 | { 7 | "op": "replace", 8 | "path": "addresses[type eq \"work\"]", 9 | "value": { 10 | "type": "work", 11 | "streetAddress": "911 Universal City Plaza", 12 | "locality": "Hollywood", 13 | "region": "CA", 14 | "postalCode": "91608", 15 | "country": "US", 16 | "formatted": "911 Universal City Plaza\nHollywood, CA 91608 US", 17 | "primary": true 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /samples/rfc7644-3.6-error-not_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:Error" 4 | ], 5 | "detail": "Resource 2819c223-7f76-453a-919d-413861904646 not found", 6 | "status": "404" 7 | } 8 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.1-bulk_request-circular_conflict.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:BulkRequest" 4 | ], 5 | "Operations": [ 6 | { 7 | "method": "POST", 8 | "path": "/Groups", 9 | "bulkId": "qwerty", 10 | "data": { 11 | "schemas": [ 12 | "urn:ietf:params:scim:schemas:core:2.0:Group" 13 | ], 14 | "displayName": "Group A", 15 | "members": [ 16 | { 17 | "type": "Group", 18 | "value": "bulkId:ytrewq" 19 | } 20 | ] 21 | } 22 | }, 23 | { 24 | "method": "POST", 25 | "path": "/Groups", 26 | "bulkId": "ytrewq", 27 | "data": { 28 | "schemas": [ 29 | "urn:ietf:params:scim:schemas:core:2.0:Group" 30 | ], 31 | "displayName": "Group B", 32 | "members": [ 33 | { 34 | "type": "Group", 35 | "value": "bulkId:qwerty" 36 | } 37 | ] 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.1-list_response-circular_reference.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:ListResponse" 4 | ], 5 | "totalResults": 2, 6 | "Resources": [ 7 | { 8 | "id": "c3a26dd3-27a0-4dec-a2ac-ce211e105f97", 9 | "schemas": [ 10 | "urn:ietf:params:scim:schemas:core:2.0:Group" 11 | ], 12 | "displayName": "Group A", 13 | "meta": { 14 | "resourceType": "Group", 15 | "created": "2011-08-01T18:29:49.793000Z", 16 | "lastModified": "2011-08-01T18:29:51.135000Z", 17 | "location": "https://example.com/v2/Groups/c3a26dd3-27a0-4dec-a2ac-ce211e105f97", 18 | "version": "W\\/\"mvwNGaxB5SDq074p\"" 19 | }, 20 | "members": [ 21 | { 22 | "value": "6c5bb468-14b2-4183-baf2-06d523e03bd3", 23 | "$ref": "https://example.com/v2/Groups/6c5bb468-14b2-4183-baf2-06d523e03bd3", 24 | "type": "Group" 25 | } 26 | ] 27 | }, 28 | { 29 | "id": "6c5bb468-14b2-4183-baf2-06d523e03bd3", 30 | "schemas": [ 31 | "urn:ietf:params:scim:schemas:core:2.0:Group" 32 | ], 33 | "displayName": "Group B", 34 | "meta": { 35 | "resourceType": "Group", 36 | "created": "2011-08-01T18:29:50.873000Z", 37 | "lastModified": "2011-08-01T18:29:50.873000Z", 38 | "location": "https://example.com/v2/Groups/6c5bb468-14b2-4183-baf2-06d523e03bd3", 39 | "version": "W\\/\"wGB85s2QJMjiNnuI\"" 40 | }, 41 | "members": [ 42 | { 43 | "value": "c3a26dd3-27a0-4dec-a2ac-ce211e105f97", 44 | "$ref": "https://example.com/v2/Groups/c3a26dd3-27a0-4dec-a2ac-ce211e105f97", 45 | "type": "Group" 46 | } 47 | ] 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.2-bulk_request-enterprise_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:BulkRequest" 4 | ], 5 | "Operations": [ 6 | { 7 | "method": "POST", 8 | "path": "/Users", 9 | "bulkId": "qwerty", 10 | "data": { 11 | "schemas": [ 12 | "urn:ietf:params:scim:schemas:core:2.0:User" 13 | ], 14 | "userName": "Alice" 15 | } 16 | }, 17 | { 18 | "method": "POST", 19 | "path": "/Users", 20 | "bulkId": "ytrewq", 21 | "data": { 22 | "schemas": [ 23 | "urn:ietf:params:scim:schemas:core:2.0:User", 24 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" 25 | ], 26 | "userName": "Bob", 27 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { 28 | "employeeNumber": "11250", 29 | "manager": { 30 | "value": "bulkId:qwerty" 31 | } 32 | } 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.2-bulk_request-temporary_identifier.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:BulkRequest" 4 | ], 5 | "Operations": [ 6 | { 7 | "method": "POST", 8 | "path": "/Users", 9 | "bulkId": "qwerty", 10 | "data": { 11 | "schemas": [ 12 | "urn:ietf:params:scim:schemas:core:2.0:User" 13 | ], 14 | "userName": "Alice" 15 | } 16 | }, 17 | { 18 | "method": "POST", 19 | "path": "/Groups", 20 | "bulkId": "ytrewq", 21 | "data": { 22 | "schemas": [ 23 | "urn:ietf:params:scim:schemas:core:2.0:Group" 24 | ], 25 | "displayName": "Tour Guides", 26 | "members": [ 27 | { 28 | "type": "User", 29 | "value": "bulkId:qwerty" 30 | } 31 | ] 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.2-bulk_response-temporary_identifier.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:BulkResponse" 4 | ], 5 | "Operations": [ 6 | { 7 | "location": "https://example.com/v2/Users/92b725cd-9465-4e7d-8c16-01f8e146b87a", 8 | "method": "POST", 9 | "bulkId": "qwerty", 10 | "version": "W\\/\"4weymrEsh5O6cAEK\"", 11 | "status": "201" 12 | }, 13 | { 14 | "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", 15 | "method": "POST", 16 | "bulkId": "ytrewq", 17 | "version": "W\\/\"lha5bbazU3fNvfe5\"", 18 | "status": "201" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.3-bulk_request-multiple_operations.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:BulkRequest" 4 | ], 5 | "failOnErrors": 1, 6 | "Operations": [ 7 | { 8 | "method": "POST", 9 | "path": "/Users", 10 | "bulkId": "qwerty", 11 | "data": { 12 | "schemas": [ 13 | "urn:ietf:params:scim:api:messages:2.0:User" 14 | ], 15 | "userName": "Alice" 16 | } 17 | }, 18 | { 19 | "method": "PUT", 20 | "path": "/Users/b7c14771-226c-4d05-8860-134711653041", 21 | "version": "W\\/\"3694e05e9dff591\"", 22 | "data": { 23 | "schemas": [ 24 | "urn:ietf:params:scim:schemas:core:2.0:User" 25 | ], 26 | "id": "b7c14771-226c-4d05-8860-134711653041", 27 | "userName": "Bob" 28 | } 29 | }, 30 | { 31 | "method": "PATCH", 32 | "path": "/Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc", 33 | "version": "W/\"edac3253e2c0ef2\"", 34 | "data": [ 35 | { 36 | "op": "remove", 37 | "path": "nickName" 38 | }, 39 | { 40 | "op": "add", 41 | "path": "userName", 42 | "value": "Dave" 43 | } 44 | ] 45 | }, 46 | { 47 | "method": "DELETE", 48 | "path": "/Users/e9025315-6bea-44e1-899c-1e07454e468b", 49 | "version": "W\\/\"0ee8add0a938e1a\"" 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.3-bulk_response-error_invalid_syntax.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:BulkResponse" 4 | ], 5 | "Operations": [ 6 | { 7 | "method": "POST", 8 | "bulkId": "qwerty", 9 | "status": "400", 10 | "response": { 11 | "schemas": [ 12 | "urn:ietf:params:scim:api:messages:2.0:Error" 13 | ], 14 | "scimType": "invalidSyntax", 15 | "detail": "Request is unparsable, syntactically incorrect, or violates schema.", 16 | "status": "400" 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.3-bulk_response-multiple_errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:BulkResponse" 4 | ], 5 | "Operations": [ 6 | { 7 | "method": "POST", 8 | "bulkId": "qwerty", 9 | "status": "400", 10 | "response": { 11 | "schemas": [ 12 | "urn:ietf:params:scim:api:messages:2.0:Error" 13 | ], 14 | "scimType": "invalidSyntax", 15 | "detail": "Request is unparsable, syntactically incorrect, or violates schema.", 16 | "status": "400" 17 | } 18 | }, 19 | { 20 | "location": "https://example.com/v2/Users/b7c14771-226c-4d05-8860-134711653041", 21 | "method": "PUT", 22 | "status": "412", 23 | "response": { 24 | "schemas": [ 25 | "urn:ietf:params:scim:api:messages:2.0:Error" 26 | ], 27 | "detail": "Failed to update. Resource changed on the server.", 28 | "status": "412" 29 | } 30 | }, 31 | { 32 | "location": "https://example.com/v2/Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc", 33 | "method": "PATCH", 34 | "status": "412", 35 | "response": { 36 | "schemas": [ 37 | "urn:ietf:params:scim:api:messages:2.0:Error" 38 | ], 39 | "detail": "Failed to update. Resource changed on the server.", 40 | "status": "412" 41 | } 42 | }, 43 | { 44 | "location": "https://example.com/v2/Users/e9025315-6bea-44e1-899c-1e07454e468b", 45 | "method": "DELETE", 46 | "status": "404", 47 | "response": { 48 | "schemas": [ 49 | "urn:ietf:params:scim:api:messages:2.0:Error" 50 | ], 51 | "detail": "Resource does not exist.", 52 | "status": "404" 53 | } 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.3-bulk_response-multiple_operations.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:BulkResponse" 4 | ], 5 | "Operations": [ 6 | { 7 | "location": "https://example.com/v2/Users/92b725cd-9465-4e7d-8c16-01f8e146b87a", 8 | "method": "POST", 9 | "bulkId": "qwerty", 10 | "version": "W\\/\"oY4m4wn58tkVjJxK\"", 11 | "status": "201" 12 | }, 13 | { 14 | "location": "https://example.com/v2/Users/b7c14771-226c-4d05-8860-134711653041", 15 | "method": "PUT", 16 | "version": "W\\/\"huJj29dMNgu3WXPD\"", 17 | "status": "200" 18 | }, 19 | { 20 | "location": "https://example.com/v2/Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc", 21 | "method": "PATCH", 22 | "version": "W\\/\"huJj29dMNgu3WXPD\"", 23 | "status": "200" 24 | }, 25 | { 26 | "location": "https://example.com/v2/Users/e9025315-6bea-44e1-899c-1e07454e468b", 27 | "method": "DELETE", 28 | "status": "204" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.3-error-invalid_syntax.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:Error" 4 | ], 5 | "scimType": "invalidSyntax", 6 | "detail": "Request is unparsable, syntactically incorrect, or violates schema.", 7 | "status": "400" 8 | } 9 | -------------------------------------------------------------------------------- /samples/rfc7644-3.7.4-error-payload_too_large.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas": [ 3 | "urn:ietf:params:scim:api:messages:2.0:Error" 4 | ], 5 | "status": "413", 6 | "detail": "The size of the bulk operation exceeds the maxPayloadSize (1048576)." 7 | } 8 | -------------------------------------------------------------------------------- /samples/rfc7644-3.9-user-partial_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], 3 | "id":"2819c223-7f76-453a-919d-413861904646", 4 | "userName":"bjensen" 5 | } 6 | -------------------------------------------------------------------------------- /samples/rfc7644-4-list_response-resource_types.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalResults":2, 3 | "itemsPerPage":10, 4 | "startIndex":1, 5 | "schemas":["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 6 | "Resources":[{ 7 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], 8 | "id":"User", 9 | "name":"User", 10 | "endpoint": "/Users", 11 | "description": "User Account", 12 | "schema": "urn:ietf:params:scim:schemas:core:2.0:User", 13 | "schemaExtensions": [{ 14 | "schema": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", 15 | "required": true 16 | }], 17 | "meta": { 18 | "location":"https://example.com/v2/ResourceTypes/User", 19 | "resourceType": "ResourceType" 20 | } 21 | }, 22 | { 23 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], 24 | "id":"Group", 25 | "name":"Group", 26 | "endpoint": "/Groups", 27 | "description": "Group", 28 | "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", 29 | "meta": { 30 | "location":"https://example.com/v2/ResourceTypes/Group", 31 | "resourceType": "ResourceType" 32 | } 33 | }] 34 | } 35 | -------------------------------------------------------------------------------- /scim2_models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseModel 2 | from .base import CaseExact 3 | from .base import ComplexAttribute 4 | from .base import Context 5 | from .base import ExternalReference 6 | from .base import MultiValuedComplexAttribute 7 | from .base import Mutability 8 | from .base import Reference 9 | from .base import Required 10 | from .base import Returned 11 | from .base import Uniqueness 12 | from .base import URIReference 13 | from .rfc7643.enterprise_user import EnterpriseUser 14 | from .rfc7643.enterprise_user import Manager 15 | from .rfc7643.group import Group 16 | from .rfc7643.group import GroupMember 17 | from .rfc7643.resource import AnyExtension 18 | from .rfc7643.resource import AnyResource 19 | from .rfc7643.resource import Extension 20 | from .rfc7643.resource import Meta 21 | from .rfc7643.resource import Resource 22 | from .rfc7643.resource_type import ResourceType 23 | from .rfc7643.resource_type import SchemaExtension 24 | from .rfc7643.schema import Attribute 25 | from .rfc7643.schema import Schema 26 | from .rfc7643.service_provider_config import AuthenticationScheme 27 | from .rfc7643.service_provider_config import Bulk 28 | from .rfc7643.service_provider_config import ChangePassword 29 | from .rfc7643.service_provider_config import ETag 30 | from .rfc7643.service_provider_config import Filter 31 | from .rfc7643.service_provider_config import Patch 32 | from .rfc7643.service_provider_config import ServiceProviderConfig 33 | from .rfc7643.service_provider_config import Sort 34 | from .rfc7643.user import Address 35 | from .rfc7643.user import Email 36 | from .rfc7643.user import Entitlement 37 | from .rfc7643.user import GroupMembership 38 | from .rfc7643.user import Im 39 | from .rfc7643.user import Name 40 | from .rfc7643.user import PhoneNumber 41 | from .rfc7643.user import Photo 42 | from .rfc7643.user import Role 43 | from .rfc7643.user import User 44 | from .rfc7643.user import X509Certificate 45 | from .rfc7644.bulk import BulkOperation 46 | from .rfc7644.bulk import BulkRequest 47 | from .rfc7644.bulk import BulkResponse 48 | from .rfc7644.error import Error 49 | from .rfc7644.list_response import ListResponse 50 | from .rfc7644.message import Message 51 | from .rfc7644.patch_op import PatchOp 52 | from .rfc7644.patch_op import PatchOperation 53 | from .rfc7644.search_request import SearchRequest 54 | 55 | __all__ = [ 56 | "Address", 57 | "AnyResource", 58 | "AnyExtension", 59 | "Attribute", 60 | "AuthenticationScheme", 61 | "BaseModel", 62 | "Bulk", 63 | "BulkOperation", 64 | "BulkRequest", 65 | "BulkResponse", 66 | "CaseExact", 67 | "ChangePassword", 68 | "ComplexAttribute", 69 | "Context", 70 | "ETag", 71 | "Email", 72 | "EnterpriseUser", 73 | "Entitlement", 74 | "Error", 75 | "ExternalReference", 76 | "Extension", 77 | "Filter", 78 | "Group", 79 | "GroupMember", 80 | "GroupMembership", 81 | "Im", 82 | "ListResponse", 83 | "Manager", 84 | "Message", 85 | "Meta", 86 | "Mutability", 87 | "MultiValuedComplexAttribute", 88 | "Name", 89 | "Patch", 90 | "PatchOp", 91 | "PatchOperation", 92 | "PhoneNumber", 93 | "Photo", 94 | "Reference", 95 | "Required", 96 | "Resource", 97 | "ResourceType", 98 | "Returned", 99 | "Role", 100 | "Schema", 101 | "SchemaExtension", 102 | "SearchRequest", 103 | "ServiceProviderConfig", 104 | "Sort", 105 | "Uniqueness", 106 | "URIReference", 107 | "User", 108 | "X509Certificate", 109 | ] 110 | -------------------------------------------------------------------------------- /scim2_models/constants.py: -------------------------------------------------------------------------------- 1 | PYTHON_RESERVED_WORDS = [ 2 | "False", 3 | "def", 4 | "if", 5 | "raise", 6 | "None", 7 | "del", 8 | "import", 9 | "return", 10 | "True", 11 | "elif", 12 | "in", 13 | "try", 14 | "and", 15 | "else", 16 | "is", 17 | "while", 18 | "as", 19 | "except", 20 | "lambda", 21 | "with", 22 | "assert", 23 | "finally", 24 | "nonlocal", 25 | "yield", 26 | "break", 27 | "for", 28 | "not", 29 | "class", 30 | "form", 31 | "or", 32 | "continue", 33 | "global", 34 | "pass", 35 | ] 36 | 37 | PYDANTIC_RESERVED_WORDS = ["schema"] 38 | RESERVED_WORDS = PYTHON_RESERVED_WORDS + PYDANTIC_RESERVED_WORDS 39 | -------------------------------------------------------------------------------- /scim2_models/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-scim/scim2-models/61fc8b0f56aca93bcde078dc43a2891c026fb0d7/scim2_models/py.typed -------------------------------------------------------------------------------- /scim2_models/rfc7643/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-scim/scim2-models/61fc8b0f56aca93bcde078dc43a2891c026fb0d7/scim2_models/rfc7643/__init__.py -------------------------------------------------------------------------------- /scim2_models/rfc7643/enterprise_user.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from typing import Literal 3 | from typing import Optional 4 | 5 | from pydantic import Field 6 | 7 | from ..base import ComplexAttribute 8 | from ..base import Mutability 9 | from ..base import Reference 10 | from ..base import Required 11 | from .resource import Extension 12 | 13 | 14 | class Manager(ComplexAttribute): 15 | value: Annotated[Optional[str], Required.true] = None 16 | """The id of the SCIM resource representing the User's manager.""" 17 | 18 | ref: Annotated[Optional[Reference[Literal["User"]]], Required.true] = Field( 19 | None, 20 | serialization_alias="$ref", 21 | ) 22 | """The URI of the SCIM resource representing the User's manager.""" 23 | 24 | display_name: Annotated[Optional[str], Mutability.read_only] = None 25 | """The displayName of the User's manager.""" 26 | 27 | 28 | class EnterpriseUser(Extension): 29 | schemas: Annotated[list[str], Required.true] = [ 30 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" 31 | ] 32 | 33 | employee_number: Optional[str] = None 34 | """Numeric or alphanumeric identifier assigned to a person, typically based 35 | on order of hire or association with an organization.""" 36 | 37 | cost_center: Optional[str] = None 38 | """"Identifies the name of a cost center.""" 39 | 40 | organization: Optional[str] = None 41 | """Identifies the name of an organization.""" 42 | 43 | division: Optional[str] = None 44 | """Identifies the name of a division.""" 45 | 46 | department: Optional[str] = None 47 | """Numeric or alphanumeric identifier assigned to a person, typically based 48 | on order of hire or association with an organization.""" 49 | 50 | manager: Optional[Manager] = None 51 | """The User's manager. 52 | 53 | A complex type that optionally allows service providers to represent 54 | organizational hierarchy by referencing the 'id' attribute of 55 | another User. 56 | """ 57 | -------------------------------------------------------------------------------- /scim2_models/rfc7643/group.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from typing import ClassVar 3 | from typing import Literal 4 | from typing import Optional 5 | from typing import Union 6 | 7 | from pydantic import Field 8 | 9 | from ..base import ComplexAttribute 10 | from ..base import MultiValuedComplexAttribute 11 | from ..base import Mutability 12 | from ..base import Reference 13 | from ..base import Required 14 | from .resource import Resource 15 | 16 | 17 | class GroupMember(MultiValuedComplexAttribute): 18 | value: Annotated[Optional[str], Mutability.immutable] = None 19 | """Identifier of the member of this Group.""" 20 | 21 | ref: Annotated[ 22 | Optional[Reference[Union[Literal["User"], Literal["Group"]]]], 23 | Mutability.immutable, 24 | ] = Field(None, serialization_alias="$ref") 25 | """The reference URI of a target resource, if the attribute is a 26 | reference.""" 27 | 28 | type: Annotated[Optional[str], Mutability.immutable] = Field( 29 | None, examples=["User", "Group"] 30 | ) 31 | """A label indicating the attribute's function, e.g., "work" or "home".""" 32 | 33 | display: Annotated[Optional[str], Mutability.read_only] = None 34 | 35 | 36 | class Group(Resource): 37 | schemas: Annotated[list[str], Required.true] = [ 38 | "urn:ietf:params:scim:schemas:core:2.0:Group" 39 | ] 40 | 41 | display_name: Optional[str] = None 42 | """A human-readable name for the Group.""" 43 | 44 | members: Optional[list[GroupMember]] = None 45 | """A list of members of the Group.""" 46 | 47 | Members: ClassVar[type[ComplexAttribute]] = GroupMember 48 | -------------------------------------------------------------------------------- /scim2_models/rfc7643/resource_type.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from typing import Optional 3 | from typing import get_args 4 | from typing import get_origin 5 | 6 | from pydantic import Field 7 | from typing_extensions import Self 8 | 9 | from ..base import CaseExact 10 | from ..base import ComplexAttribute 11 | from ..base import Mutability 12 | from ..base import Reference 13 | from ..base import Required 14 | from ..base import Returned 15 | from ..base import URIReference 16 | from ..utils import UNION_TYPES 17 | from .resource import Resource 18 | 19 | 20 | class SchemaExtension(ComplexAttribute): 21 | schema_: Annotated[ 22 | Optional[Reference[URIReference]], 23 | Mutability.read_only, 24 | Required.true, 25 | CaseExact.true, 26 | ] = Field(None, alias="schema") 27 | """The URI of a schema extension.""" 28 | 29 | required: Annotated[Optional[bool], Mutability.read_only, Required.true] = None 30 | """A Boolean value that specifies whether or not the schema extension is 31 | required for the resource type. 32 | 33 | If true, a resource of this type MUST include this schema extension 34 | and also include any attributes declared as required in this schema 35 | extension. If false, a resource of this type MAY omit this schema 36 | extension. 37 | """ 38 | 39 | 40 | class ResourceType(Resource): 41 | schemas: Annotated[list[str], Required.true] = [ 42 | "urn:ietf:params:scim:schemas:core:2.0:ResourceType" 43 | ] 44 | 45 | name: Annotated[Optional[str], Mutability.read_only, Required.true] = None 46 | """The resource type name. 47 | 48 | When applicable, service providers MUST specify the name, e.g., 49 | 'User'. 50 | """ 51 | 52 | description: Annotated[Optional[str], Mutability.read_only] = None 53 | """The resource type's human-readable description. 54 | 55 | When applicable, service providers MUST specify the description. 56 | """ 57 | 58 | id: Annotated[Optional[str], Mutability.read_only, Returned.default] = None 59 | """The resource type's server unique id. 60 | 61 | This is often the same value as the "name" attribute. 62 | """ 63 | 64 | endpoint: Annotated[ 65 | Optional[Reference[URIReference]], Mutability.read_only, Required.true 66 | ] = None 67 | """The resource type's HTTP-addressable endpoint relative to the Base URL, 68 | e.g., '/Users'.""" 69 | 70 | schema_: Annotated[ 71 | Optional[Reference[URIReference]], 72 | Mutability.read_only, 73 | Required.true, 74 | CaseExact.true, 75 | ] = Field(None, alias="schema") 76 | """The resource type's primary/base schema URI.""" 77 | 78 | schema_extensions: Annotated[ 79 | Optional[list[SchemaExtension]], Mutability.read_only, Required.true 80 | ] = None 81 | """A list of URIs of the resource type's schema extensions.""" 82 | 83 | @classmethod 84 | def from_resource(cls, resource_model: type[Resource]) -> Self: 85 | """Build a naive ResourceType from a resource model.""" 86 | schema = resource_model.model_fields["schemas"].default[0] 87 | name = schema.split(":")[-1] 88 | if resource_model.__pydantic_generic_metadata__["args"]: 89 | extensions = resource_model.__pydantic_generic_metadata__["args"][0] 90 | extensions = ( 91 | get_args(extensions) 92 | if get_origin(extensions) in UNION_TYPES 93 | else [extensions] 94 | ) 95 | else: 96 | extensions = [] 97 | 98 | return ResourceType( 99 | id=name, 100 | name=name, 101 | description=name, 102 | endpoint=f"/{name}s", 103 | schema_=schema, 104 | schema_extensions=[ 105 | SchemaExtension( 106 | schema_=extension.model_fields["schemas"].default[0], required=False 107 | ) 108 | for extension in extensions 109 | ], 110 | ) 111 | -------------------------------------------------------------------------------- /scim2_models/rfc7643/schema.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | from enum import Enum 4 | from typing import Annotated 5 | from typing import Any 6 | from typing import List # noqa : UP005,UP035 7 | from typing import Literal 8 | from typing import Optional 9 | from typing import Union 10 | from typing import get_origin 11 | 12 | from pydantic import Field 13 | from pydantic import create_model 14 | from pydantic import field_validator 15 | from pydantic.alias_generators import to_pascal 16 | from pydantic.alias_generators import to_snake 17 | from pydantic_core import Url 18 | 19 | from ..base import BaseModel 20 | from ..base import CaseExact 21 | from ..base import ComplexAttribute 22 | from ..base import ExternalReference 23 | from ..base import MultiValuedComplexAttribute 24 | from ..base import Mutability 25 | from ..base import Reference 26 | from ..base import Required 27 | from ..base import Returned 28 | from ..base import Uniqueness 29 | from ..base import URIReference 30 | from ..base import is_complex_attribute 31 | from ..constants import RESERVED_WORDS 32 | from ..utils import Base64Bytes 33 | from ..utils import normalize_attribute_name 34 | from .resource import Extension 35 | from .resource import Resource 36 | 37 | 38 | def make_python_identifier(identifier: str) -> str: 39 | """Sanitize string to be a suitable Python/Pydantic class attribute name.""" 40 | sanitized = re.sub(r"\W|^(?=\d)", "", identifier) 41 | if sanitized in RESERVED_WORDS: 42 | sanitized = f"{sanitized}_" 43 | 44 | return sanitized 45 | 46 | 47 | def make_python_model( 48 | obj: Union["Schema", "Attribute"], 49 | base: Optional[type[BaseModel]] = None, 50 | multiple=False, 51 | ) -> Union[Resource, Extension]: 52 | """Build a Python model from a Schema or an Attribute object.""" 53 | if isinstance(obj, Attribute): 54 | pydantic_attributes = { 55 | to_snake(make_python_identifier(attr.name)): attr.to_python() 56 | for attr in (obj.sub_attributes or []) 57 | if attr.name 58 | } 59 | base = MultiValuedComplexAttribute if multiple else ComplexAttribute 60 | 61 | else: 62 | pydantic_attributes = { 63 | to_snake(make_python_identifier(attr.name)): attr.to_python() 64 | for attr in (obj.attributes or []) 65 | if attr.name 66 | } 67 | pydantic_attributes["schemas"] = ( 68 | Annotated[list[str], Required.true], 69 | Field(default=[obj.id]), 70 | ) 71 | 72 | model_name = to_pascal(to_snake(obj.name)) 73 | model = create_model(model_name, __base__=base, **pydantic_attributes) 74 | 75 | # Set the ComplexType class as a member of the model 76 | # e.g. make Member an attribute of Group 77 | for attr_name in model.model_fields: 78 | attr_type = model.get_field_root_type(attr_name) 79 | if is_complex_attribute(attr_type): 80 | setattr(model, attr_type.__name__, attr_type) 81 | 82 | return model 83 | 84 | 85 | class Attribute(ComplexAttribute): 86 | class Type(str, Enum): 87 | string = "string" 88 | complex = "complex" 89 | boolean = "boolean" 90 | decimal = "decimal" 91 | integer = "integer" 92 | date_time = "dateTime" 93 | reference = "reference" 94 | binary = "binary" 95 | 96 | def to_python( 97 | self, 98 | multiple=False, 99 | reference_types: Optional[list[str]] = None, 100 | ) -> type: 101 | if self.value == self.reference and reference_types is not None: 102 | if reference_types == ["external"]: 103 | return Reference[ExternalReference] 104 | 105 | if reference_types == ["uri"]: 106 | return Reference[URIReference] 107 | 108 | types = tuple(Literal[t] for t in reference_types) 109 | return Reference[Union[types]] # type: ignore 110 | 111 | attr_types = { 112 | self.string: str, 113 | self.boolean: bool, 114 | self.decimal: float, 115 | self.integer: int, 116 | self.date_time: datetime, 117 | self.binary: Base64Bytes, 118 | self.complex: MultiValuedComplexAttribute 119 | if multiple 120 | else ComplexAttribute, 121 | } 122 | return attr_types[self.value] 123 | 124 | @classmethod 125 | def from_python(cls, pytype) -> str: 126 | if get_origin(pytype) == Reference: 127 | return cls.reference.value 128 | 129 | if is_complex_attribute(pytype): 130 | return cls.complex.value 131 | 132 | if pytype in (Required, CaseExact): 133 | return cls.boolean.value 134 | 135 | attr_types = { 136 | str: cls.string.value, 137 | bool: cls.boolean.value, 138 | float: cls.decimal.value, 139 | int: cls.integer.value, 140 | datetime: cls.date_time.value, 141 | Base64Bytes: cls.binary.value, 142 | } 143 | return attr_types.get(pytype, cls.string.value) 144 | 145 | name: Annotated[ 146 | Optional[str], Mutability.read_only, Required.true, CaseExact.true 147 | ] = None 148 | """The attribute's name.""" 149 | 150 | type: Annotated[Type, Mutability.read_only, Required.true] = Field( 151 | None, examples=[item.value for item in Type] 152 | ) 153 | """The attribute's data type.""" 154 | 155 | multi_valued: Annotated[Optional[bool], Mutability.read_only, Required.true] = None 156 | """A Boolean value indicating the attribute's plurality.""" 157 | 158 | description: Annotated[ 159 | Optional[str], Mutability.read_only, Required.false, CaseExact.true 160 | ] = None 161 | """The attribute's human-readable description.""" 162 | 163 | required: Annotated[Required, Mutability.read_only, Required.false] = Required.false 164 | """A Boolean value that specifies whether or not the attribute is 165 | required.""" 166 | 167 | canonical_values: Annotated[ 168 | Optional[list[str]], Mutability.read_only, CaseExact.true 169 | ] = None 170 | """A collection of suggested canonical values that MAY be used (e.g., 171 | "work" and "home").""" 172 | 173 | case_exact: Annotated[CaseExact, Mutability.read_only, Required.false] = ( 174 | CaseExact.false 175 | ) 176 | """A Boolean value that specifies whether or not a string attribute is case 177 | sensitive.""" 178 | 179 | mutability: Annotated[ 180 | Mutability, Mutability.read_only, Required.false, CaseExact.true 181 | ] = Field(Mutability.read_write, examples=[item.value for item in Mutability]) 182 | """A single keyword indicating the circumstances under which the value of 183 | the attribute can be (re)defined.""" 184 | 185 | returned: Annotated[ 186 | Returned, Mutability.read_only, Required.false, CaseExact.true 187 | ] = Field(Returned.default, examples=[item.value for item in Returned]) 188 | """A single keyword that indicates when an attribute and associated values 189 | are returned in response to a GET request or in response to a PUT, POST, or 190 | PATCH request.""" 191 | 192 | uniqueness: Annotated[ 193 | Uniqueness, Mutability.read_only, Required.false, CaseExact.true 194 | ] = Field(Uniqueness.none, examples=[item.value for item in Uniqueness]) 195 | """A single keyword value that specifies how the service provider enforces 196 | uniqueness of attribute values.""" 197 | 198 | reference_types: Annotated[ 199 | Optional[list[str]], Mutability.read_only, Required.false, CaseExact.true 200 | ] = None 201 | """A multi-valued array of JSON strings that indicate the SCIM resource 202 | types that may be referenced.""" 203 | 204 | # for python 3.9 and 3.10 compatibility, this should be 'list' and not 'List' 205 | sub_attributes: Annotated[Optional[List["Attribute"]], Mutability.read_only] = None # noqa: UP006 206 | """When an attribute is of type "complex", "subAttributes" defines a set of 207 | sub-attributes.""" 208 | 209 | def to_python(self) -> Optional[tuple[Any, Field]]: 210 | """Build tuple suited to be passed to pydantic 'create_model'.""" 211 | if not self.name: 212 | return None 213 | 214 | attr_type = self.type.to_python(self.multi_valued, self.reference_types) 215 | 216 | if attr_type in (ComplexAttribute, MultiValuedComplexAttribute): 217 | attr_type = make_python_model(obj=self, multiple=self.multi_valued) 218 | 219 | if self.multi_valued: 220 | attr_type = list[attr_type] # type: ignore 221 | 222 | annotation = Annotated[ 223 | Optional[attr_type], # type: ignore 224 | self.required, 225 | self.case_exact, 226 | self.mutability, 227 | self.returned, 228 | self.uniqueness, 229 | ] 230 | 231 | field = Field( 232 | description=self.description, 233 | examples=self.canonical_values, 234 | serialization_alias=self.name, 235 | validation_alias=normalize_attribute_name(self.name), 236 | default=None, 237 | ) 238 | 239 | return annotation, field 240 | 241 | def get_attribute(self, attribute_name: str) -> Optional["Attribute"]: 242 | """Find an attribute by its name.""" 243 | for sub_attribute in self.sub_attributes or []: 244 | if sub_attribute.name == attribute_name: 245 | return sub_attribute 246 | return None 247 | 248 | def __getitem__(self, name): 249 | """Find an attribute by its name.""" 250 | if attribute := self.get_attribute(name): 251 | return attribute 252 | raise KeyError(f"This attribute has no '{name}' sub-attribute") 253 | 254 | 255 | class Schema(Resource): 256 | schemas: Annotated[list[str], Required.true] = [ 257 | "urn:ietf:params:scim:schemas:core:2.0:Schema" 258 | ] 259 | 260 | id: Annotated[Optional[str], Mutability.read_only, Required.true] = None 261 | """The unique URI of the schema.""" 262 | 263 | name: Annotated[ 264 | Optional[str], Mutability.read_only, Returned.default, Required.true 265 | ] = None 266 | """The schema's human-readable name.""" 267 | 268 | description: Annotated[Optional[str], Mutability.read_only, Returned.default] = None 269 | """The schema's human-readable description.""" 270 | 271 | attributes: Annotated[ 272 | Optional[list[Attribute]], Mutability.read_only, Required.true 273 | ] = None 274 | """A complex type that defines service provider attributes and their 275 | qualities via the following set of sub-attributes.""" 276 | 277 | @field_validator("id") 278 | @classmethod 279 | def urn_id(cls, value: str) -> str: 280 | """Ensure that schema ids are URI, as defined in RFC7643 §7.""" 281 | return str(Url(value)) 282 | 283 | def get_attribute(self, attribute_name: str) -> Optional[Attribute]: 284 | """Find an attribute by its name.""" 285 | for attribute in self.attributes or []: 286 | if attribute.name == attribute_name: 287 | return attribute 288 | return None 289 | 290 | def __getitem__(self, name): 291 | """Find an attribute by its name.""" 292 | if attribute := self.get_attribute(name): 293 | return attribute 294 | raise KeyError(f"This schema has no '{name}' attribute") 295 | -------------------------------------------------------------------------------- /scim2_models/rfc7643/service_provider_config.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Annotated 3 | from typing import Optional 4 | 5 | from pydantic import Field 6 | 7 | from ..base import ComplexAttribute 8 | from ..base import ExternalReference 9 | from ..base import Mutability 10 | from ..base import Reference 11 | from ..base import Required 12 | from ..base import Returned 13 | from ..base import Uniqueness 14 | from .resource import Resource 15 | 16 | 17 | class Patch(ComplexAttribute): 18 | supported: Annotated[Optional[bool], Mutability.read_only, Required.true] = None 19 | """A Boolean value specifying whether or not the operation is supported.""" 20 | 21 | 22 | class Bulk(ComplexAttribute): 23 | supported: Annotated[Optional[bool], Mutability.read_only, Required.true] = None 24 | """A Boolean value specifying whether or not the operation is supported.""" 25 | 26 | max_operations: Annotated[Optional[int], Mutability.read_only, Required.true] = None 27 | """An integer value specifying the maximum number of operations.""" 28 | 29 | max_payload_size: Annotated[Optional[int], Mutability.read_only, Required.true] = ( 30 | None 31 | ) 32 | """An integer value specifying the maximum payload size in bytes.""" 33 | 34 | 35 | class Filter(ComplexAttribute): 36 | supported: Annotated[Optional[bool], Mutability.read_only, Required.true] = None 37 | """A Boolean value specifying whether or not the operation is supported.""" 38 | 39 | max_results: Annotated[Optional[int], Mutability.read_only, Required.true] = None 40 | """A Boolean value specifying whether or not the operation is supported.""" 41 | 42 | 43 | class ChangePassword(ComplexAttribute): 44 | supported: Annotated[Optional[bool], Mutability.read_only, Required.true] = None 45 | """A Boolean value specifying whether or not the operation is supported.""" 46 | 47 | 48 | class Sort(ComplexAttribute): 49 | supported: Annotated[Optional[bool], Mutability.read_only, Required.true] = None 50 | """A Boolean value specifying whether or not the operation is supported.""" 51 | 52 | 53 | class ETag(ComplexAttribute): 54 | supported: Annotated[Optional[bool], Mutability.read_only, Required.true] = None 55 | """A Boolean value specifying whether or not the operation is supported.""" 56 | 57 | 58 | class AuthenticationScheme(ComplexAttribute): 59 | class Type(str, Enum): 60 | oauth = "oauth" 61 | oauth2 = "oauth2" 62 | oauthbearertoken = "oauthbearertoken" 63 | httpbasic = "httpbasic" 64 | httpdigest = "httpdigest" 65 | 66 | type: Annotated[Optional[Type], Mutability.read_only, Required.true] = Field( 67 | None, 68 | examples=["oauth", "oauth2", "oauthbreakertoken", "httpbasic", "httpdigest"], 69 | ) 70 | """The authentication scheme.""" 71 | 72 | name: Annotated[Optional[str], Mutability.read_only, Required.true] = None 73 | """The common authentication scheme name, e.g., HTTP Basic.""" 74 | 75 | description: Annotated[Optional[str], Mutability.read_only, Required.true] = None 76 | """A description of the authentication scheme.""" 77 | 78 | spec_uri: Annotated[ 79 | Optional[Reference[ExternalReference]], Mutability.read_only 80 | ] = None 81 | """An HTTP-addressable URL pointing to the authentication scheme's 82 | specification.""" 83 | 84 | documentation_uri: Annotated[ 85 | Optional[Reference[ExternalReference]], Mutability.read_only 86 | ] = None 87 | """An HTTP-addressable URL pointing to the authentication scheme's usage 88 | documentation.""" 89 | 90 | primary: Annotated[Optional[bool], Mutability.read_only] = None 91 | """A Boolean value indicating the 'primary' or preferred attribute value 92 | for this attribute, e.g., the preferred mailing address or primary email 93 | address.""" 94 | 95 | 96 | class ServiceProviderConfig(Resource): 97 | schemas: Annotated[list[str], Required.true] = [ 98 | "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" 99 | ] 100 | 101 | id: Annotated[ 102 | Optional[str], Mutability.read_only, Returned.default, Uniqueness.global_ 103 | ] = None 104 | """A unique identifier for a SCIM resource as defined by the service 105 | provider.""" 106 | # RFC7643 §5 107 | # Unlike other core 108 | # resources, the "id" attribute is not required for the service 109 | # provider configuration resource 110 | 111 | documentation_uri: Annotated[ 112 | Optional[Reference[ExternalReference]], Mutability.read_only 113 | ] = None 114 | """An HTTP-addressable URL pointing to the service provider's human- 115 | consumable help documentation.""" 116 | 117 | patch: Annotated[Optional[Patch], Mutability.read_only, Required.true] = None 118 | """A complex type that specifies PATCH configuration options.""" 119 | 120 | bulk: Annotated[Optional[Bulk], Mutability.read_only, Required.true] = None 121 | """A complex type that specifies bulk configuration options.""" 122 | 123 | filter: Annotated[Optional[Filter], Mutability.read_only, Required.true] = None 124 | """A complex type that specifies FILTER options.""" 125 | 126 | change_password: Annotated[ 127 | Optional[ChangePassword], Mutability.read_only, Required.true 128 | ] = None 129 | """A complex type that specifies configuration options related to changing 130 | a password.""" 131 | 132 | sort: Annotated[Optional[Sort], Mutability.read_only, Required.true] = None 133 | """A complex type that specifies sort result options.""" 134 | 135 | etag: Annotated[Optional[ETag], Mutability.read_only, Required.true] = None 136 | """A complex type that specifies ETag configuration options.""" 137 | 138 | authentication_schemes: Annotated[ 139 | Optional[list[AuthenticationScheme]], Mutability.read_only, Required.true 140 | ] = None 141 | """A complex type that specifies supported authentication scheme 142 | properties.""" 143 | -------------------------------------------------------------------------------- /scim2_models/rfc7644/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-scim/scim2-models/61fc8b0f56aca93bcde078dc43a2891c026fb0d7/scim2_models/rfc7644/__init__.py -------------------------------------------------------------------------------- /scim2_models/rfc7644/bulk.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Annotated 3 | from typing import Any 4 | from typing import Optional 5 | 6 | from pydantic import Field 7 | from pydantic import PlainSerializer 8 | 9 | from ..base import ComplexAttribute 10 | from ..base import Required 11 | from ..utils import int_to_str 12 | from .message import Message 13 | 14 | 15 | class BulkOperation(ComplexAttribute): 16 | class Method(str, Enum): 17 | post = "POST" 18 | put = "PUT" 19 | patch = "PATCH" 20 | delete = "DELETE" 21 | 22 | method: Optional[Method] = None 23 | """The HTTP method of the current operation.""" 24 | 25 | bulk_id: Optional[str] = None 26 | """The transient identifier of a newly created resource, unique within a 27 | bulk request and created by the client.""" 28 | 29 | version: Optional[str] = None 30 | """The current resource version.""" 31 | 32 | path: Optional[str] = None 33 | """The resource's relative path to the SCIM service provider's root.""" 34 | 35 | data: Optional[Any] = None 36 | """The resource data as it would appear for a single SCIM POST, PUT, or 37 | PATCH operation.""" 38 | 39 | location: Optional[str] = None 40 | """The resource endpoint URL.""" 41 | 42 | response: Optional[Any] = None 43 | """The HTTP response body for the specified request operation.""" 44 | 45 | status: Annotated[Optional[int], PlainSerializer(int_to_str)] = None 46 | """The HTTP response status code for the requested operation.""" 47 | 48 | 49 | class BulkRequest(Message): 50 | """Bulk request as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. 51 | 52 | .. todo:: 53 | 54 | The models for Bulk operations are defined, but their behavior is not implemented nor tested yet. 55 | """ 56 | 57 | schemas: Annotated[list[str], Required.true] = [ 58 | "urn:ietf:params:scim:api:messages:2.0:BulkRequest" 59 | ] 60 | 61 | fail_on_errors: Optional[int] = None 62 | """An integer specifying the number of errors that the service provider 63 | will accept before the operation is terminated and an error response is 64 | returned.""" 65 | 66 | operations: Optional[list[BulkOperation]] = Field( 67 | None, serialization_alias="Operations" 68 | ) 69 | """Defines operations within a bulk job.""" 70 | 71 | 72 | class BulkResponse(Message): 73 | """Bulk response as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`. 74 | 75 | .. todo:: 76 | 77 | The models for Bulk operations are defined, but their behavior is not implemented nor tested yet. 78 | """ 79 | 80 | schemas: Annotated[list[str], Required.true] = [ 81 | "urn:ietf:params:scim:api:messages:2.0:BulkResponse" 82 | ] 83 | 84 | operations: Optional[list[BulkOperation]] = Field( 85 | None, serialization_alias="Operations" 86 | ) 87 | """Defines operations within a bulk job.""" 88 | -------------------------------------------------------------------------------- /scim2_models/rfc7644/error.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from typing import Optional 3 | 4 | from pydantic import PlainSerializer 5 | 6 | from ..base import Required 7 | from ..utils import int_to_str 8 | from .message import Message 9 | 10 | 11 | class Error(Message): 12 | """Representation of SCIM API errors.""" 13 | 14 | schemas: Annotated[list[str], Required.true] = [ 15 | "urn:ietf:params:scim:api:messages:2.0:Error" 16 | ] 17 | 18 | status: Annotated[Optional[int], PlainSerializer(int_to_str)] = None 19 | """The HTTP status code (see Section 6 of [RFC7231]) expressed as a JSON 20 | string.""" 21 | 22 | scim_type: Optional[str] = None 23 | """A SCIM detail error keyword.""" 24 | 25 | detail: Optional[str] = None 26 | """A detailed human-readable message.""" 27 | 28 | @classmethod 29 | def make_invalid_filter_error(cls): 30 | """Pre-defined error intended to be raised when the specified filter syntax was invalid (does not comply with :rfc:`Figure 1 of RFC7644 <7644#section-3.4.2.2>`), or the specified attribute and filter comparison combination is not supported.""" 31 | return Error( 32 | status=400, 33 | scim_type="invalidFilter", 34 | detail="""The specified filter syntax was invalid (does not comply with Figure 1 of RFC7644), or the specified attribute and filter comparison combination is not supported.""", 35 | ) 36 | 37 | @classmethod 38 | def make_too_many_error(cls): 39 | """Pre-defined error intended to be raised when the specified filter yields many more results than the server is willing to calculate or process. For example, a filter such as ``(userName pr)`` by itself would return all entries with a ``userName`` and MAY not be acceptable to the service provider.""" 40 | return Error( 41 | status=400, 42 | scim_type="tooMany", 43 | detail="""The specified filter yields many more results than the server is willing to calculate or process. For example, a filter such as "(userName pr)" by itself would return all entries with a "userName" and MAY not be acceptable to the service provider.""", 44 | ) 45 | 46 | @classmethod 47 | def make_uniqueness_error(cls): 48 | """Pre-defined error intended to be raised when One or more of the attribute values are already in use or are reserved.""" 49 | return Error( 50 | status=409, 51 | scim_type="uniqueness", 52 | detail="""One or more of the attribute values are already in use or are reserved.""", 53 | ) 54 | 55 | @classmethod 56 | def make_mutability_error(cls): 57 | """Pre-defined error intended to be raised when the attempted modification is not compatible with the target attribute's mutability or current state (e.g., modification of an "immutable" attribute with an existing value).""" 58 | return Error( 59 | status=400, 60 | scim_type="mutability", 61 | detail="""The attempted modification is not compatible with the target attribute's mutability or current state (e.g., modification of an "immutable" attribute with an existing value).""", 62 | ) 63 | 64 | @classmethod 65 | def make_invalid_syntax_error(cls): 66 | """Pre-defined error intended to be raised when the request body message structure was invalid or did not conform to the request schema.""" 67 | return Error( 68 | status=400, 69 | scim_type="invalidSyntax", 70 | detail="""The request body message structure was invalid or did not conform to the request schema.""", 71 | ) 72 | 73 | @classmethod 74 | def make_invalid_path_error(cls): 75 | """Pre-defined error intended to be raised when the "path" attribute was invalid or malformed (see :rfc:`Figure 7 of RFC7644 <7644#section-3.5.2>`).""" 76 | return Error( 77 | status=400, 78 | scim_type="invalidPath", 79 | detail="""The "path" attribute was invalid or malformed (see Figure 7 of RFC7644).""", 80 | ) 81 | 82 | @classmethod 83 | def make_no_target_error(cls): 84 | """Pre-defined error intended to be raised when the specified "path" did not yield an attribute or attribute value that could be operated on. This occurs when the specified "path" value contains a filter that yields no match.""" 85 | return Error( 86 | status=400, 87 | scim_type="noTarget", 88 | detail="""The specified "path" did not yield an attribute or attribute value that could be operated on. This occurs when the specified "path" value contains a filter that yields no match.""", 89 | ) 90 | 91 | @classmethod 92 | def make_invalid_value_error(cls): 93 | """Pre-defined error intended to be raised when a required value was missing, or the value specified was not compatible with the operation or attribute type (see :rfc:`Section 2.2 of RFC7643 <7643#section-2.2>`), or resource schema (see :rfc:`Section 4 of RFC7643 <7643#section-4>`).""" 94 | return Error( 95 | status=400, 96 | scim_type="invalidValue", 97 | detail="""A required value was missing, or the value specified was not compatible with the operation or attribute type (see Section 2.2 of RFC7643), or resource schema (see Section 4 of RFC7643).""", 98 | ) 99 | 100 | @classmethod 101 | def make_invalid_version_error(cls): 102 | """Pre-defined error intended to be raised when the specified SCIM protocol version is not supported (see :rfc:`Section 3.13 of RFC7644 <7644#section-3.13>`).""" 103 | return Error( 104 | status=400, 105 | scim_type="invalidVers", 106 | detail="""The specified SCIM protocol version is not supported (see Section 3.13 of RFC7644).""", 107 | ) 108 | 109 | @classmethod 110 | def make_sensitive_error(cls): 111 | """Pre-defined error intended to be raised when the specified request cannot be completed, due to the passing of sensitive (e.g., personal) information in a request URI. For example, personal information SHALL NOT be transmitted over request URIs. See :rfc:`Section 7.5.2 of RFC7644 <7644#section-7.5.2>`.""" 112 | return Error( 113 | status=400, 114 | scim_type="sensitive", 115 | detail="""The specified request cannot be completed, due to the passing of sensitive (e.g., personal) information in a request URI. For example, personal information SHALL NOT be transmitted over request URIs. See Section 7.5.2. of RFC7644""", 116 | ) 117 | -------------------------------------------------------------------------------- /scim2_models/rfc7644/list_response.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from typing import Any 3 | from typing import Generic 4 | from typing import Optional 5 | from typing import Union 6 | from typing import get_args 7 | from typing import get_origin 8 | 9 | from pydantic import Discriminator 10 | from pydantic import Field 11 | from pydantic import Tag 12 | from pydantic import ValidationInfo 13 | from pydantic import ValidatorFunctionWrapHandler 14 | from pydantic import model_validator 15 | from pydantic_core import PydanticCustomError 16 | from typing_extensions import Self 17 | 18 | from ..base import BaseModel 19 | from ..base import BaseModelType 20 | from ..base import Context 21 | from ..base import Required 22 | from ..rfc7643.resource import AnyResource 23 | from ..utils import UNION_TYPES 24 | from .message import Message 25 | 26 | 27 | class ListResponseMetaclass(BaseModelType): 28 | def tagged_resource_union(resource_union): 29 | """Build Discriminated Unions, so pydantic can guess which class are needed to instantiate by inspecting a payload. 30 | 31 | https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions 32 | """ 33 | if get_origin(resource_union) not in UNION_TYPES: 34 | return resource_union 35 | 36 | resource_types = get_args(resource_union) 37 | 38 | def get_schema_from_payload(payload: Any) -> Optional[str]: 39 | if not payload: 40 | return None 41 | 42 | payload_schemas = ( 43 | payload.get("schemas", []) 44 | if isinstance(payload, dict) 45 | else payload.schemas 46 | ) 47 | 48 | resource_types_schemas = [ 49 | resource_type.model_fields["schemas"].default[0] 50 | for resource_type in resource_types 51 | ] 52 | common_schemas = [ 53 | schema for schema in payload_schemas if schema in resource_types_schemas 54 | ] 55 | return common_schemas[0] if common_schemas else None 56 | 57 | discriminator = Discriminator(get_schema_from_payload) 58 | 59 | def get_tag(resource_type: type[BaseModel]) -> Tag: 60 | return Tag(resource_type.model_fields["schemas"].default[0]) 61 | 62 | tagged_resources = [ 63 | Annotated[resource_type, get_tag(resource_type)] 64 | for resource_type in resource_types 65 | ] 66 | union = Union[tuple(tagged_resources)] 67 | return Annotated[union, discriminator] 68 | 69 | def __new__(cls, name, bases, attrs, **kwargs): 70 | if kwargs.get("__pydantic_generic_metadata__") and kwargs[ 71 | "__pydantic_generic_metadata__" 72 | ].get("args"): 73 | tagged_union = cls.tagged_resource_union( 74 | kwargs["__pydantic_generic_metadata__"]["args"][0] 75 | ) 76 | kwargs["__pydantic_generic_metadata__"]["args"] = (tagged_union,) 77 | 78 | klass = super().__new__(cls, name, bases, attrs, **kwargs) 79 | return klass 80 | 81 | 82 | class ListResponse(Message, Generic[AnyResource], metaclass=ListResponseMetaclass): 83 | schemas: Annotated[list[str], Required.true] = [ 84 | "urn:ietf:params:scim:api:messages:2.0:ListResponse" 85 | ] 86 | 87 | total_results: Optional[int] = None 88 | """The total number of results returned by the list or query operation.""" 89 | 90 | start_index: Optional[int] = None 91 | """The 1-based index of the first result in the current set of list 92 | results.""" 93 | 94 | items_per_page: Optional[int] = None 95 | """The number of resources returned in a list response page.""" 96 | 97 | resources: Optional[list[AnyResource]] = Field( 98 | None, serialization_alias="Resources" 99 | ) 100 | """A multi-valued list of complex objects containing the requested 101 | resources.""" 102 | 103 | @model_validator(mode="wrap") 104 | @classmethod 105 | def check_results_number( 106 | cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo 107 | ) -> Self: 108 | """Validate result numbers. 109 | 110 | :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>` indicates that: 111 | 112 | - 'totalResults' is required 113 | - 'resources' must be set if 'totalResults' is non-zero. 114 | """ 115 | obj = handler(value) 116 | 117 | if ( 118 | not info.context 119 | or not info.context.get("scim") 120 | or not Context.is_response(info.context["scim"]) 121 | ): 122 | return obj 123 | 124 | if obj.total_results is None: 125 | raise PydanticCustomError( 126 | "required_error", 127 | "Field 'total_results' is required but value is missing or null", 128 | ) 129 | 130 | if obj.total_results > 0 and not obj.resources: 131 | raise PydanticCustomError( 132 | "no_resource_error", 133 | "Field 'resources' is missing or null but 'total_results' is non-zero.", 134 | ) 135 | 136 | return obj 137 | -------------------------------------------------------------------------------- /scim2_models/rfc7644/message.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from ..base import BaseModel 4 | from ..base import Required 5 | 6 | 7 | class Message(BaseModel): 8 | """SCIM protocol messages as defined by :rfc:`RFC7644 §3.1 <7644#section-3.1>`.""" 9 | 10 | schemas: Annotated[list[str], Required.true] 11 | -------------------------------------------------------------------------------- /scim2_models/rfc7644/patch_op.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Annotated 3 | from typing import Any 4 | from typing import Optional 5 | 6 | from pydantic import Field 7 | from pydantic import field_validator 8 | 9 | from ..base import ComplexAttribute 10 | from ..base import Required 11 | from .message import Message 12 | 13 | 14 | class PatchOperation(ComplexAttribute): 15 | class Op(str, Enum): 16 | replace_ = "replace" 17 | remove = "remove" 18 | add = "add" 19 | 20 | op: Optional[Optional[Op]] = None 21 | """Each PATCH operation object MUST have exactly one "op" member, whose 22 | value indicates the operation to perform and MAY be one of "add", "remove", 23 | or "replace". 24 | 25 | .. note:: 26 | 27 | For the sake of compatibility with Microsoft Entra, 28 | despite :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`, op is case-insensitive. 29 | """ 30 | 31 | path: Optional[str] = None 32 | """The "path" attribute value is a String containing an attribute path 33 | describing the target of the operation.""" 34 | 35 | value: Optional[Any] = None 36 | 37 | @field_validator("op", mode="before") 38 | @classmethod 39 | def normalize_op(cls, v): 40 | """Ignorecase for op. 41 | 42 | This brings 43 | `compatibility with Microsoft Entra `_: 44 | 45 | Don't require a case-sensitive match on structural elements in SCIM, 46 | in particular PATCH op operation values, as defined in section 3.5.2. 47 | Microsoft Entra ID emits the values of op as Add, Replace, and Remove. 48 | """ 49 | if isinstance(v, str): 50 | return v.lower() 51 | return v 52 | 53 | 54 | class PatchOp(Message): 55 | """Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. 56 | 57 | .. todo:: 58 | 59 | The models for Patch operations are defined, but their behavior is not implemented nor tested yet. 60 | """ 61 | 62 | schemas: Annotated[list[str], Required.true] = [ 63 | "urn:ietf:params:scim:api:messages:2.0:PatchOp" 64 | ] 65 | 66 | operations: Optional[list[PatchOperation]] = Field( 67 | None, serialization_alias="Operations" 68 | ) 69 | """The body of an HTTP PATCH request MUST contain the attribute 70 | "Operations", whose value is an array of one or more PATCH operations.""" 71 | -------------------------------------------------------------------------------- /scim2_models/rfc7644/search_request.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Annotated 3 | from typing import Optional 4 | 5 | from pydantic import field_validator 6 | from pydantic import model_validator 7 | 8 | from ..base import Required 9 | from .message import Message 10 | 11 | 12 | class SearchRequest(Message): 13 | """SearchRequest object defined at RFC7644. 14 | 15 | https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3 16 | """ 17 | 18 | schemas: Annotated[list[str], Required.true] = [ 19 | "urn:ietf:params:scim:api:messages:2.0:SearchRequest" 20 | ] 21 | 22 | attributes: Optional[list[str]] = None 23 | """A multi-valued list of strings indicating the names of resource 24 | attributes to return in the response, overriding the set of attributes that 25 | would be returned by default.""" 26 | 27 | excluded_attributes: Optional[list[str]] = None 28 | """A multi-valued list of strings indicating the names of resource 29 | attributes to be removed from the default set of attributes to return.""" 30 | 31 | filter: Optional[str] = None 32 | """The filter string used to request a subset of resources.""" 33 | 34 | sort_by: Optional[str] = None 35 | """A string indicating the attribute whose value SHALL be used to order the 36 | returned responses.""" 37 | 38 | class SortOrder(str, Enum): 39 | ascending = "ascending" 40 | descending = "descending" 41 | 42 | sort_order: Optional[SortOrder] = None 43 | """A string indicating the order in which the "sortBy" parameter is 44 | applied.""" 45 | 46 | start_index: Optional[int] = None 47 | """An integer indicating the 1-based index of the first query result.""" 48 | 49 | @field_validator("start_index") 50 | @classmethod 51 | def start_index_floor(cls, value: int) -> int: 52 | """According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, start_index values less than 1 are interpreted as 1. 53 | 54 | A value less than 1 SHALL be interpreted as 1. 55 | """ 56 | return None if value is None else max(1, value) 57 | 58 | count: Optional[int] = None 59 | """An integer indicating the desired maximum number of query results per 60 | page.""" 61 | 62 | @field_validator("count") 63 | @classmethod 64 | def count_floor(cls, value: int) -> int: 65 | """According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, count values less than 0 are interpreted as 0. 66 | 67 | A negative value SHALL be interpreted as 0. 68 | """ 69 | return None if value is None else max(0, value) 70 | 71 | @model_validator(mode="after") 72 | def attributes_validator(self): 73 | if self.attributes and self.excluded_attributes: 74 | raise ValueError( 75 | "'attributes' and 'excluded_attributes' are mutually exclusive" 76 | ) 77 | 78 | return self 79 | 80 | @property 81 | def start_index_0(self): 82 | """The 0 indexed start index.""" 83 | return self.start_index - 1 if self.start_index is not None else None 84 | 85 | @property 86 | def stop_index_0(self): 87 | """The 0 indexed stop index.""" 88 | return ( 89 | self.start_index_0 + self.count 90 | if self.start_index_0 is not None and self.count is not None 91 | else None 92 | ) 93 | -------------------------------------------------------------------------------- /scim2_models/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | from typing import Annotated 4 | from typing import Literal 5 | from typing import Optional 6 | from typing import Union 7 | 8 | from pydantic import EncodedBytes 9 | from pydantic import EncoderProtocol 10 | from pydantic.alias_generators import to_snake 11 | from pydantic_core import PydanticCustomError 12 | 13 | try: 14 | from types import UnionType # type: ignore 15 | 16 | UNION_TYPES = [Union, UnionType] 17 | except ImportError: 18 | # Python 3.9 has no UnionType 19 | UNION_TYPES = [Union] 20 | 21 | 22 | def int_to_str(status: Optional[int]) -> Optional[str]: 23 | return None if status is None else str(status) 24 | 25 | 26 | # Copied from Pydantic 2.10 repository 27 | class Base64Encoder(EncoderProtocol): # pragma: no cover 28 | """Standard (non-URL-safe) Base64 encoder.""" 29 | 30 | @classmethod 31 | def decode(cls, data: bytes) -> bytes: 32 | """Decode the data from base64 encoded bytes to original bytes data. 33 | 34 | Args: 35 | data: The data to decode. 36 | 37 | Returns: 38 | The decoded data. 39 | 40 | """ 41 | try: 42 | return base64.b64decode(data) 43 | except ValueError as e: 44 | raise PydanticCustomError( 45 | "base64_decode", "Base64 decoding error: '{error}'", {"error": str(e)} 46 | ) from e 47 | 48 | @classmethod 49 | def encode(cls, value: bytes) -> bytes: 50 | """Encode the data from bytes to a base64 encoded bytes. 51 | 52 | Args: 53 | value: The data to encode. 54 | 55 | Returns: 56 | The encoded data. 57 | 58 | """ 59 | return base64.b64encode(value) 60 | 61 | @classmethod 62 | def get_json_format(cls) -> Literal["base64"]: 63 | """Get the JSON format for the encoded data. 64 | 65 | Returns: 66 | The JSON format for the encoded data. 67 | 68 | """ 69 | return "base64" 70 | 71 | 72 | # Compatibility with Pydantic <2.10 73 | # https://pydantic.dev/articles/pydantic-v2-10-release#use-b64decode-and-b64encode-for-base64bytes-and-base64str-types 74 | Base64Bytes = Annotated[bytes, EncodedBytes(encoder=Base64Encoder)] 75 | 76 | 77 | def to_camel(string: str) -> str: 78 | """Transform strings to camelCase. 79 | 80 | This method is used for attribute name serialization. This is more 81 | or less the pydantic implementation, but it does not add uppercase 82 | on alphanumerical characters after specials characters. For instance 83 | '$ref' stays '$ref'. 84 | """ 85 | snake = to_snake(string) 86 | camel = re.sub(r"_+([0-9A-Za-z]+)", lambda m: m.group(1).title(), snake) 87 | return camel 88 | 89 | 90 | def normalize_attribute_name(attribute_name: str) -> str: 91 | """Remove all non-alphabetical characters and lowerise a string. 92 | 93 | This method is used for attribute name validation. 94 | """ 95 | is_extension_attribute = ":" in attribute_name 96 | if not is_extension_attribute: 97 | attribute_name = re.sub(r"[\W_]+", "", attribute_name) 98 | 99 | return attribute_name.lower() 100 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-scim/scim2-models/61fc8b0f56aca93bcde078dc43a2891c026fb0d7/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def load_sample(): 8 | def wrapped(filename): 9 | with open(f"samples/{filename}") as fd: 10 | return json.load(fd) 11 | 12 | return wrapped 13 | -------------------------------------------------------------------------------- /tests/test_dynamic_schemas.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from typing import Annotated 3 | from typing import Optional 4 | 5 | from scim2_models.base import Context 6 | from scim2_models.base import Required 7 | from scim2_models.rfc7643.enterprise_user import EnterpriseUser 8 | from scim2_models.rfc7643.group import Group 9 | from scim2_models.rfc7643.resource import Resource 10 | from scim2_models.rfc7643.resource_type import ResourceType 11 | from scim2_models.rfc7643.schema import Schema 12 | from scim2_models.rfc7643.service_provider_config import ServiceProviderConfig 13 | from scim2_models.rfc7643.user import User 14 | 15 | 16 | def canonic_schema(schema): 17 | """Remove descriptions and sort attributes so schemas are easily comparable.""" 18 | schema["meta"] = None 19 | schema["name"] = None 20 | schema["schemas"] = None 21 | schema["description"] = None 22 | schema["attributes"].sort(key=operator.itemgetter("name")) 23 | for attr in schema["attributes"]: 24 | attr["description"] = None 25 | if attr.get("subAttributes"): 26 | attr["subAttributes"].sort(key=operator.itemgetter("name")) 27 | for subattr in attr["subAttributes"]: 28 | subattr["description"] = None 29 | if subattr.get("subAttributes"): 30 | subattr["subAttributes"].sort(key=operator.itemgetter("name")) 31 | for subsubattr in subattr["subAttributes"]: 32 | subsubattr["description"] = None 33 | 34 | 35 | def test_dynamic_group_schema(load_sample): 36 | sample = Schema.model_validate( 37 | load_sample("rfc7643-8.7.1-schema-group.json") 38 | ).model_dump() 39 | schema = Group.to_schema().model_dump() 40 | 41 | canonic_schema(schema) 42 | canonic_schema(sample) 43 | assert sample == schema 44 | 45 | 46 | def test_dynamic_user_schema(load_sample): 47 | sample = Schema.model_validate( 48 | load_sample("rfc7643-8.7.1-schema-user.json") 49 | ).model_dump() 50 | schema = User.to_schema().model_dump() 51 | 52 | canonic_schema(schema) 53 | canonic_schema(sample) 54 | 55 | # Remove attributes that are redefined from implicit complexattributes 56 | for i, attr in enumerate(sample["attributes"]): 57 | if attr["name"] in ("roles", "entitlements"): 58 | sample["attributes"][i]["subAttributes"] = [ 59 | subattr 60 | for subattr in attr["subAttributes"] 61 | if subattr["name"] not in ("type", "primary", "value", "display") 62 | ] 63 | 64 | if attr["name"] == "x509Certificates": 65 | sample["attributes"][i]["subAttributes"] = [ 66 | subattr 67 | for subattr in attr["subAttributes"] 68 | if subattr["name"] not in ("type", "primary", "display") 69 | ] 70 | 71 | assert sample == schema 72 | 73 | 74 | def test_dynamic_enterprise_user_schema(load_sample): 75 | sample = Schema.model_validate( 76 | load_sample("rfc7643-8.7.1-schema-enterprise_user.json") 77 | ).model_dump() 78 | schema = EnterpriseUser.to_schema().model_dump() 79 | 80 | canonic_schema(schema) 81 | canonic_schema(sample) 82 | assert sample == schema 83 | 84 | 85 | def test_dynamic_resource_type_schema(load_sample): 86 | sample = Schema.model_validate( 87 | load_sample("rfc7643-8.7.2-schema-resource_type.json") 88 | ).model_dump() 89 | schema = ResourceType.to_schema().model_dump() 90 | 91 | canonic_schema(schema) 92 | canonic_schema(sample) 93 | assert sample == schema 94 | 95 | 96 | def test_dynamic_service_provider_config_schema(load_sample): 97 | sample = Schema.model_validate( 98 | load_sample("rfc7643-8.7.2-schema-service_provider_configuration.json") 99 | ).model_dump() 100 | schema = ServiceProviderConfig.to_schema().model_dump() 101 | 102 | canonic_schema(schema) 103 | canonic_schema(sample) 104 | 105 | schema["attributes"] = [ 106 | attr for attr in schema["attributes"] if attr["name"] != "id" 107 | ] 108 | for i, attr in enumerate(schema["attributes"]): 109 | if attr["name"] == "authenticationSchemes": 110 | schema["attributes"][i]["subAttributes"] = [ 111 | subattr 112 | for subattr in attr["subAttributes"] 113 | if subattr["name"] not in ("type", "primary") 114 | ] 115 | 116 | assert sample == schema 117 | 118 | 119 | def test_dynamic_schema_schema(load_sample): 120 | sample = Schema.model_validate( 121 | load_sample("rfc7643-8.7.2-schema-schema.json") 122 | ).model_dump() 123 | schema = Schema.to_schema().model_dump() 124 | 125 | canonic_schema(schema) 126 | canonic_schema(sample) 127 | assert sample == schema 128 | 129 | 130 | def test_dump_with_context(): 131 | models = [User, EnterpriseUser, Group, ResourceType, Schema, ServiceProviderConfig] 132 | for model in models: 133 | model.to_schema().model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) 134 | 135 | 136 | def test_inheritance(): 137 | """Check that parent attributes are included in the schema.""" 138 | 139 | class Foo(Resource): 140 | schemas: Annotated[list[str], Required.true] = [ 141 | "urn:ietf:params:scim:schemas:core:2.0:Foo" 142 | ] 143 | 144 | foo: Optional[str] = None 145 | 146 | class Bar(Foo): 147 | bar: Optional[str] = None 148 | 149 | schema = Bar.to_schema() 150 | assert schema.model_dump() == { 151 | "attributes": [ 152 | { 153 | "caseExact": False, 154 | "multiValued": False, 155 | "mutability": "readWrite", 156 | "name": "foo", 157 | "required": False, 158 | "returned": "default", 159 | "type": "string", 160 | "uniqueness": "none", 161 | }, 162 | { 163 | "caseExact": False, 164 | "multiValued": False, 165 | "mutability": "readWrite", 166 | "name": "bar", 167 | "required": False, 168 | "returned": "default", 169 | "type": "string", 170 | "uniqueness": "none", 171 | }, 172 | ], 173 | "description": "Bar", 174 | "id": "urn:ietf:params:scim:schemas:core:2.0:Foo", 175 | "name": "Bar", 176 | "schemas": [ 177 | "urn:ietf:params:scim:schemas:core:2.0:Schema", 178 | ], 179 | } 180 | -------------------------------------------------------------------------------- /tests/test_enterprise_user.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from scim2_models import Address 4 | from scim2_models import Email 5 | from scim2_models import EnterpriseUser 6 | from scim2_models import Im 7 | from scim2_models import PhoneNumber 8 | from scim2_models import Photo 9 | from scim2_models import Reference 10 | from scim2_models import User 11 | 12 | 13 | def test_enterprise_user(load_sample): 14 | payload = load_sample("rfc7643-8.3-enterprise_user.json") 15 | obj = User[EnterpriseUser].model_validate(payload) 16 | 17 | assert obj.schemas == [ 18 | "urn:ietf:params:scim:schemas:core:2.0:User", 19 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", 20 | ] 21 | assert obj.id == "2819c223-7f76-453a-919d-413861904646" 22 | assert obj.external_id == "701984" 23 | assert obj.user_name == "bjensen@example.com" 24 | assert obj.name 25 | assert obj.name.formatted == "Ms. Barbara J Jensen, III" 26 | assert obj.name.family_name == "Jensen" 27 | assert obj.name.given_name == "Barbara" 28 | assert obj.name.middle_name == "Jane" 29 | assert obj.name.honorific_prefix == "Ms." 30 | assert obj.name.honorific_suffix == "III" 31 | assert obj.display_name == "Babs Jensen" 32 | assert obj.nick_name == "Babs" 33 | assert obj.profile_url == Reference("https://login.example.com/bjensen") 34 | assert obj.emails[0].value == "bjensen@example.com" 35 | assert obj.emails[0].type == Email.Type.work 36 | assert obj.emails[0].primary is True 37 | assert obj.emails[1].value == "babs@jensen.org" 38 | assert obj.emails[1].type == Email.Type.home 39 | assert obj.addresses[0].type == Address.Type.work 40 | assert obj.addresses[0].street_address == "100 Universal City Plaza" 41 | assert obj.addresses[0].locality == "Hollywood" 42 | assert obj.addresses[0].region == "CA" 43 | assert obj.addresses[0].postal_code == "91608" 44 | assert obj.addresses[0].country == "USA" 45 | assert ( 46 | obj.addresses[0].formatted 47 | == "100 Universal City Plaza\nHollywood, CA 91608 USA" 48 | ) 49 | assert obj.addresses[0].primary is True 50 | assert obj.addresses[1].type == Address.Type.home 51 | assert obj.addresses[1].street_address == "456 Hollywood Blvd" 52 | assert obj.addresses[1].locality == "Hollywood" 53 | assert obj.addresses[1].region == "CA" 54 | assert obj.addresses[1].postal_code == "91608" 55 | assert obj.addresses[1].country == "USA" 56 | assert obj.addresses[1].formatted == "456 Hollywood Blvd\nHollywood, CA 91608 USA" 57 | assert obj.phone_numbers[0].value == "555-555-5555" 58 | assert obj.phone_numbers[0].type == PhoneNumber.Type.work 59 | assert obj.phone_numbers[1].value == "555-555-4444" 60 | assert obj.phone_numbers[1].type == PhoneNumber.Type.mobile 61 | assert obj.ims[0].value == "someaimhandle" 62 | assert obj.ims[0].type == Im.Type.aim 63 | assert obj.photos[0].value == Reference( 64 | "https://photos.example.com/profilephoto/72930000000Ccne/F" 65 | ) 66 | assert obj.photos[0].type == Photo.Type.photo 67 | assert obj.photos[1].value == Reference( 68 | "https://photos.example.com/profilephoto/72930000000Ccne/T" 69 | ) 70 | assert obj.photos[1].type == Photo.Type.thumbnail 71 | assert obj.user_type == "Employee" 72 | assert obj.title == "Tour Guide" 73 | assert obj.preferred_language == "en-US" 74 | assert obj.locale == "en-US" 75 | assert obj.timezone == "America/Los_Angeles" 76 | assert obj.active is True 77 | assert obj.password == "t1meMa$heen" 78 | assert obj.groups[0].value == "e9e30dba-f08f-4109-8486-d5c6a331660a" 79 | assert obj.groups[0].ref == Reference( 80 | "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a" 81 | ) 82 | assert obj.groups[0].display == "Tour Guides" 83 | assert obj.groups[1].value == "fc348aa8-3835-40eb-a20b-c726e15c55b5" 84 | assert obj.groups[1].ref == Reference( 85 | "https://example.com/v2/Groups/fc348aa8-3835-40eb-a20b-c726e15c55b5" 86 | ) 87 | assert obj.groups[1].display == "Employees" 88 | assert obj.groups[2].value == "71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7" 89 | assert obj.groups[2].ref == Reference( 90 | "https://example.com/v2/Groups/71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7" 91 | ) 92 | assert obj.groups[2].display == "US Employees" 93 | assert obj.x509_certificates[0].value == ( 94 | b"0\x82\x03C0\x82\x02\xac\xa0\x03\x02\x01\x02\x02\x02\x10\x000\r\x06\t*\x86H\x86\xf7\r\x01\x01\x05\x05\x000N1\x0b0\t\x06\x03U\x04\x06\x13\x02US1\x130\x11\x06\x03U\x04\x08\x0c\nCalifornia1\x140\x12\x06\x03U\x04\n\x0c\x0bexample.com1\x140\x12\x06\x03U\x04\x03\x0c\x0bexample.com0\x1e\x17\r111022062431Z\x17\r121004062431Z0\x7f1\x0b0\t\x06\x03U\x04\x06\x13\x02US1\x130\x11\x06\x03U\x04\x08\x0c\nCalifornia1\x140\x12\x06\x03U\x04\n\x0c\x0bexample.com1!0\x1f\x06\x03U\x04\x03\x0c\x18Ms. Barbara J Jensen III1\"0 \x06\t*\x86H\x86\xf7\r\x01\t\x01\x16\x13bjensen@example.com0\x82\x01\"0\r\x06\t*\x86H\x86\xf7\r\x01\x01\x01\x05\x00\x03\x82\x01\x0f\x000\x82\x01\n\x02\x82\x01\x01\x00\xec\xaa\xfe\r\xc7l\xfc\x949\x1b\x07\xa3$W\x01 \xfe\xbc\xd9}\xf1\xa68\xac\xe7\xa0\n\xd3f\xdc\xd4R\xe0\xcd\xd2\xc8\xf1\xab\xa8G\xe7\x02\xf7\xf5k\x87\x9bz\xe8y\x10 \xe7@\xe2\xe9\xc7\x87@\x1ag\x8cK\xe4\xf8Umr\x0f0\x1eo\x00\xf2\xa9\xcf>b=(\xbc\xc4\xef\x12/\xb2;H8\\\x05Ra\xa9Z\xab\xdcx%\x94A\xbaP)Cts\xbb\x9eB\xee\xc1z\xbc\x1d\xc2\x12*F\xd3\xe5aSU\xf1Y\xce'O\x97\xc1\xd9\xec8W\x91\x92\x11\xb4\x9c\x01\xc1\xea\xb8n\xf9\xb7\x84\xcdN\xb3\xb5\x10\x1fNYK\xa7\x15\x0e\x0c\x1e(\xdc\x1d,\xba\xd3\xe7X\xa4I\x01\xb7\r\x8a\xe5\xf9\xfb{\xf3U\x10D\x8a\xb1\x83\n\x82}q.\x0e(\xa7>\x1d$\x92\xf2\"\xe68\xafV\xedY\xf5\x08\xb0\x95\x81\x1dE\xb2\xc9\xe32P\x06`\xebHd\xccb~%E\xcd\x87\x80s\xb8\x0e\xa0\x7fns\x15\xa2\xb1\xc1\xd0 \xba\x01]P\xa0R\xb8\x18\xf7\xb5/\x02\x03\x01\x00\x01\xa3{0y0\t\x06\x03U\x1d\x13\x04\x020\x000,\x06\t`\x86H\x01\x86\xf8B\x01\r\x04\x1f\x16\x1dOpenSSL Generated Certificate0\x1d\x06\x03U\x1d\x0e\x04\x16\x04\x14\xf2\x90\xf4SK\xecd\x8b\x1a\x03^\xa5/\xc1'\xf1\xbct\x17\xf80\x1f\x06\x03U\x1d#\x04\x180\x16\x80\x14tg\x8a\x8a\xd7\x1a\x17\xb8'\xce\xc3p\x0f\x1e\xf4\xf2J\x9aV\xdd0\r\x06\t*\x86H\x86\xf7\r\x01\x01\x05\x05\x00\x03\x81\x81\x00\x03\xcdR\xb0Y\xceu\x82m6\x0eSr\xaf\xbf\x07!\x03\xac\x18'\xba\xcct\x8eZ\x14\x84\x1c\x8f0Ed\xa0\xc6w'\xb8\xf5f\x02<\xac\x06\xce\x90\xd9\xe0_\xcf\xa9)\xf4\xe2\x0f=Q\x0b\x8f\x9d\xc7\xca\x14\xe9\x96\xbe\xe0\xd2WR9K\xe4+\xd5\xe8\x11\x18o_\x90\x00Bp\x8a\xd4\xd5\xbf\x10\x7f\x03\xae\xe0\xe3o\xef\xce\x00-\xa1\x15\x1e\x0e\x8b\xf5\xf8ab\x05\x9f\x864_\xdc\x01\x82\x9c2\xd1\x9c\xae\xcd\xa2\xf7\xb6d$\xca" 95 | ) 96 | assert obj.meta.resource_type == "User" 97 | assert obj.meta.created == datetime.datetime( 98 | 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc 99 | ) 100 | assert obj.meta.last_modified == datetime.datetime( 101 | 2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc 102 | ) 103 | assert obj.meta.version == 'W\\/"3694e05e9dff591"' 104 | assert ( 105 | obj.meta.location 106 | == "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" 107 | ) 108 | 109 | assert obj[EnterpriseUser].employee_number == "701984" 110 | assert obj[EnterpriseUser].cost_center == "4130" 111 | assert obj[EnterpriseUser].organization == "Universal Studios" 112 | assert obj[EnterpriseUser].division == "Theme Park" 113 | assert obj[EnterpriseUser].department == "Tour Operations" 114 | assert obj[EnterpriseUser].manager.value == "26118915-6090-4610-87e4-49d8ca9f808d" 115 | assert obj[EnterpriseUser].manager.ref == Reference( 116 | "https://example.com/v2/Users/26118915-6090-4610-87e4-49d8ca9f808d" 117 | ) 118 | assert obj[EnterpriseUser].manager.display_name == "John Smith" 119 | 120 | assert obj.model_dump(exclude_unset=True) == payload 121 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | from scim2_models.rfc7644.error import Error 2 | 3 | 4 | def test_predefined_errors(): 5 | for gen in ( 6 | Error.make_invalid_filter_error, 7 | Error.make_too_many_error, 8 | Error.make_uniqueness_error, 9 | Error.make_mutability_error, 10 | Error.make_invalid_syntax_error, 11 | Error.make_invalid_path_error, 12 | Error.make_no_target_error, 13 | Error.make_invalid_value_error, 14 | Error.make_invalid_version_error, 15 | Error.make_sensitive_error, 16 | ): 17 | assert isinstance(gen(), Error) 18 | -------------------------------------------------------------------------------- /tests/test_group.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from scim2_models import Group 4 | from scim2_models import Reference 5 | 6 | 7 | def test_group(load_sample): 8 | payload = load_sample("rfc7643-8.4-group.json") 9 | obj = Group.model_validate(payload) 10 | 11 | assert obj.schemas == ["urn:ietf:params:scim:schemas:core:2.0:Group"] 12 | assert obj.id == "e9e30dba-f08f-4109-8486-d5c6a331660a" 13 | assert obj.display_name == "Tour Guides" 14 | assert obj.members[0].value == "2819c223-7f76-453a-919d-413861904646" 15 | assert obj.members[0].ref == Reference( 16 | "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" 17 | ) 18 | assert obj.members[0].display == "Babs Jensen" 19 | assert obj.members[1].value == "902c246b-6245-4190-8e05-00816be7344a" 20 | assert obj.members[1].ref == Reference( 21 | "https://example.com/v2/Users/902c246b-6245-4190-8e05-00816be7344a" 22 | ) 23 | assert obj.members[1].display == "Mandy Pepperidge" 24 | assert obj.meta.resource_type == "Group" 25 | assert obj.meta.created == datetime.datetime( 26 | 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc 27 | ) 28 | assert obj.meta.last_modified == datetime.datetime( 29 | 2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc 30 | ) 31 | assert obj.meta.version == 'W\\/"3694e05e9dff592"' 32 | assert ( 33 | obj.meta.location 34 | == "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a" 35 | ) 36 | 37 | assert obj.model_dump() == payload 38 | -------------------------------------------------------------------------------- /tests/test_list_response.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from typing import Union 3 | 4 | import pytest 5 | from pydantic import ValidationError 6 | 7 | from scim2_models import Context 8 | from scim2_models import EnterpriseUser 9 | from scim2_models import Group 10 | from scim2_models import ListResponse 11 | from scim2_models import Required 12 | from scim2_models import Resource 13 | from scim2_models import ResourceType 14 | from scim2_models import ServiceProviderConfig 15 | from scim2_models import User 16 | 17 | 18 | def test_user(load_sample): 19 | resource_payload = load_sample("rfc7643-8.1-user-minimal.json") 20 | payload = { 21 | "totalResults": 1, 22 | "itemsPerPage": 10, 23 | "startIndex": 1, 24 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 25 | "Resources": [resource_payload], 26 | } 27 | response = ListResponse[User].model_validate(payload) 28 | obj = response.resources[0] 29 | assert isinstance(obj, User) 30 | 31 | 32 | def test_enterprise_user(load_sample): 33 | resource_payload = load_sample("rfc7643-8.3-enterprise_user.json") 34 | payload = { 35 | "totalResults": 1, 36 | "itemsPerPage": 10, 37 | "startIndex": 1, 38 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 39 | "Resources": [resource_payload], 40 | } 41 | response = ListResponse[User[EnterpriseUser]].model_validate(payload) 42 | obj = response.resources[0] 43 | assert isinstance(obj, User) 44 | 45 | 46 | def test_group(load_sample): 47 | resource_payload = load_sample("rfc7643-8.4-group.json") 48 | payload = { 49 | "totalResults": 1, 50 | "itemsPerPage": 10, 51 | "startIndex": 1, 52 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 53 | "Resources": [resource_payload], 54 | } 55 | response = ListResponse[Group].model_validate(payload) 56 | obj = response.resources[0] 57 | assert isinstance(obj, Group) 58 | 59 | 60 | def test_service_provider_configuration(load_sample): 61 | resource_payload = load_sample("rfc7643-8.5-service_provider_configuration.json") 62 | payload = { 63 | "totalResults": 1, 64 | "itemsPerPage": 10, 65 | "startIndex": 1, 66 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 67 | "Resources": [resource_payload], 68 | } 69 | response = ListResponse[ServiceProviderConfig].model_validate(payload) 70 | obj = response.resources[0] 71 | assert isinstance(obj, ServiceProviderConfig) 72 | 73 | 74 | def test_resource_type(load_sample): 75 | """Test returning a list of resource types. 76 | 77 | https://datatracker.ietf.org/doc/html/rfc7644#section-4 78 | """ 79 | user_resource_type_payload = load_sample("rfc7643-8.6-resource_type-user.json") 80 | group_resource_type_payload = load_sample("rfc7643-8.6-resource_type-group.json") 81 | payload = { 82 | "totalResults": 2, 83 | "itemsPerPage": 10, 84 | "startIndex": 1, 85 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 86 | "Resources": [user_resource_type_payload, group_resource_type_payload], 87 | } 88 | response = ListResponse[ResourceType].model_validate(payload) 89 | obj = response.resources[0] 90 | assert isinstance(obj, ResourceType) 91 | 92 | 93 | def test_mixed_types(load_sample): 94 | """Check that given the good type, a ListResponse can handle several resource types.""" 95 | user_payload = load_sample("rfc7643-8.1-user-minimal.json") 96 | group_payload = load_sample("rfc7643-8.4-group.json") 97 | payload = { 98 | "totalResults": 2, 99 | "itemsPerPage": 10, 100 | "startIndex": 1, 101 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 102 | "Resources": [user_payload, group_payload], 103 | } 104 | response = ListResponse[Union[User, Group]].model_validate(payload) 105 | user, group = response.resources 106 | assert isinstance(user, User) 107 | assert isinstance(group, Group) 108 | assert response.model_dump() == payload 109 | 110 | 111 | class Foobar(Resource): 112 | schemas: Annotated[list[str], Required.true] = ["foobarschema"] 113 | 114 | 115 | def test_mixed_types_type_missing(load_sample): 116 | """Check that ValidationError are raised when unknown schemas are met.""" 117 | user_payload = load_sample("rfc7643-8.1-user-minimal.json") 118 | group_payload = load_sample("rfc7643-8.4-group.json") 119 | payload = { 120 | "totalResults": 2, 121 | "itemsPerPage": 10, 122 | "startIndex": 1, 123 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 124 | "Resources": [user_payload, group_payload], 125 | } 126 | 127 | ListResponse[Union[User, Group]].model_validate(payload) 128 | 129 | with pytest.raises(ValidationError): 130 | ListResponse[Union[User, Foobar]].model_validate(payload) 131 | 132 | with pytest.raises(ValidationError): 133 | ListResponse[User].model_validate(payload) 134 | 135 | 136 | def test_missing_resource_payload(load_sample): 137 | """Check that validation fails if resources schemas are missing.""" 138 | payload = { 139 | "totalResults": 2, 140 | "itemsPerPage": 10, 141 | "startIndex": 1, 142 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 143 | "Resources": [{}], 144 | } 145 | 146 | with pytest.raises(ValidationError): 147 | ListResponse[Union[User, Group]].model_validate(payload, strict=True) 148 | 149 | # TODO: This should raise a ValidationError 150 | ListResponse[User].model_validate(payload, strict=True) 151 | 152 | 153 | def test_missing_resource_schema(load_sample): 154 | """Check that validation fails if resources schemas are missing.""" 155 | payload = { 156 | "totalResults": 2, 157 | "itemsPerPage": 10, 158 | "startIndex": 1, 159 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 160 | "Resources": [{"id": "foobar"}], 161 | } 162 | 163 | with pytest.raises(ValidationError): 164 | ListResponse[Union[User, Group]].model_validate(payload, strict=True) 165 | 166 | # TODO: This should raise a ValidationError 167 | ListResponse[User].model_validate(payload, strict=True) 168 | 169 | 170 | def test_zero_results(): 171 | """:rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>` indicates that ListResponse.Resources is required when ListResponse.totalResults is non- zero. 172 | 173 | This MAY be a subset of the full set of resources if pagination 174 | (Section 3.4.2.4) is requested. REQUIRED if "totalResults" is non- 175 | zero. 176 | """ 177 | payload = { 178 | "totalResults": 1, 179 | "Resources": [ 180 | { 181 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], 182 | "userName": "foobar", 183 | "id": "foobar", 184 | } 185 | ], 186 | } 187 | ListResponse[User].model_validate(payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE) 188 | 189 | payload = {"totalResults": 1, "Resources": []} 190 | with pytest.raises(ValidationError): 191 | ListResponse[User].model_validate( 192 | payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE 193 | ) 194 | 195 | payload = {"totalResults": 1} 196 | with pytest.raises(ValidationError): 197 | ListResponse[User].model_validate( 198 | payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE 199 | ) 200 | 201 | 202 | def test_list_response_schema_ordering(): 203 | """Test that the "schemas" attribute order does not impact behavior. 204 | 205 | https://datatracker.ietf.org/doc/html/rfc7643#section-3 206 | """ 207 | payload = { 208 | "totalResults": 1, 209 | "Resources": [ 210 | { 211 | "schemas": [ 212 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", 213 | "urn:ietf:params:scim:schemas:core:2.0:User", 214 | ], 215 | "userName": "bjensen@example.com", 216 | } 217 | ], 218 | } 219 | ListResponse[Union[User[EnterpriseUser], Group]].model_validate(payload) 220 | 221 | 222 | def test_total_results_required(): 223 | """ListResponse.total_results is required.""" 224 | payload = { 225 | "Resources": [ 226 | { 227 | "schemas": [ 228 | "urn:ietf:params:scim:schemas:core:2.0:User", 229 | ], 230 | "userName": "bjensen@example.com", 231 | "id": "foobar", 232 | } 233 | ], 234 | } 235 | 236 | with pytest.raises( 237 | ValidationError, 238 | match="Field 'total_results' is required but value is missing or null", 239 | ): 240 | ListResponse[User].model_validate( 241 | payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE 242 | ) 243 | -------------------------------------------------------------------------------- /tests/test_model_serialization.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from typing import Optional 3 | 4 | import pytest 5 | 6 | from scim2_models.base import ComplexAttribute 7 | from scim2_models.base import Context 8 | from scim2_models.base import Mutability 9 | from scim2_models.base import Required 10 | from scim2_models.base import Returned 11 | from scim2_models.rfc7643.resource import Resource 12 | 13 | 14 | class SubRetModel(ComplexAttribute): 15 | always_returned: Annotated[Optional[str], Returned.always] = None 16 | never_returned: Annotated[Optional[str], Returned.never] = None 17 | default_returned: Annotated[Optional[str], Returned.default] = None 18 | request_returned: Annotated[Optional[str], Returned.request] = None 19 | 20 | 21 | class SupRetResource(Resource): 22 | schemas: Annotated[list[str], Required.true] = ["org:example:SupRetResource"] 23 | 24 | always_returned: Annotated[Optional[str], Returned.always] = None 25 | never_returned: Annotated[Optional[str], Returned.never] = None 26 | default_returned: Annotated[Optional[str], Returned.default] = None 27 | request_returned: Annotated[Optional[str], Returned.request] = None 28 | 29 | sub: Optional[SubRetModel] = None 30 | 31 | 32 | class MutResource(Resource): 33 | schemas: Annotated[list[str], Required.true] = ["org:example:MutResource"] 34 | 35 | read_only: Annotated[Optional[str], Mutability.read_only] = None 36 | read_write: Annotated[Optional[str], Mutability.read_write] = None 37 | immutable: Annotated[Optional[str], Mutability.immutable] = None 38 | write_only: Annotated[Optional[str], Mutability.write_only] = None 39 | 40 | 41 | @pytest.fixture 42 | def ret_resource(): 43 | return SupRetResource( 44 | id="id", 45 | always_returned="x", 46 | never_returned="x", 47 | default_returned="x", 48 | request_returned="x", 49 | sub=SubRetModel( 50 | always_returned="x", 51 | never_returned="x", 52 | default_returned="x", 53 | request_returned="x", 54 | ), 55 | ) 56 | 57 | 58 | @pytest.fixture 59 | def mut_resource(): 60 | return MutResource( 61 | id="id", 62 | read_only="x", 63 | read_write="x", 64 | immutable="x", 65 | write_only="x", 66 | ) 67 | 68 | 69 | def test_model_dump_json(mut_resource): 70 | assert ( 71 | mut_resource.model_dump_json() 72 | == '{"schemas":["org:example:MutResource"],"id":"id","readOnly":"x","readWrite":"x","immutable":"x","writeOnly":"x"}' 73 | ) 74 | 75 | 76 | def test_dump_default(mut_resource): 77 | """By default, everything is dumped.""" 78 | assert mut_resource.model_dump() == { 79 | "schemas": ["org:example:MutResource"], 80 | "id": "id", 81 | "readOnly": "x", 82 | "readWrite": "x", 83 | "immutable": "x", 84 | "writeOnly": "x", 85 | } 86 | 87 | assert mut_resource.model_dump(scim_ctx=Context.DEFAULT) == { 88 | "schemas": ["org:example:MutResource"], 89 | "id": "id", 90 | "readOnly": "x", 91 | "readWrite": "x", 92 | "immutable": "x", 93 | "writeOnly": "x", 94 | } 95 | 96 | assert mut_resource.model_dump(scim_ctx=None) == { 97 | "schemas": ["org:example:MutResource"], 98 | "id": "id", 99 | "read_only": "x", 100 | "read_write": "x", 101 | "immutable": "x", 102 | "write_only": "x", 103 | } 104 | 105 | 106 | def test_dump_creation_request(mut_resource): 107 | """Test query building for resource creation request. 108 | 109 | Attributes marked as: 110 | - Mutability.read_write are dumped 111 | - Mutability.immutable are dumped 112 | - Mutability.write_only are dumped 113 | - Mutability.read_only are not dumped 114 | """ 115 | assert mut_resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST) == { 116 | "schemas": ["org:example:MutResource"], 117 | "readWrite": "x", 118 | "immutable": "x", 119 | "writeOnly": "x", 120 | } 121 | 122 | 123 | def test_dump_query_request(mut_resource): 124 | """Test query building for resource query request. 125 | 126 | Attributes marked as: 127 | - Mutability.read_write are dumped 128 | - Mutability.immutable are dumped 129 | - Mutability.write_only are not dumped 130 | - Mutability.read_only are dumped 131 | """ 132 | assert mut_resource.model_dump(scim_ctx=Context.RESOURCE_QUERY_REQUEST) == { 133 | "schemas": ["org:example:MutResource"], 134 | "id": "id", 135 | "readOnly": "x", 136 | "readWrite": "x", 137 | "immutable": "x", 138 | } 139 | 140 | 141 | def test_dump_replacement_request(mut_resource): 142 | """Test query building for resource model replacement requests. 143 | 144 | Attributes marked as: 145 | - Mutability.read_write are dumped 146 | - Mutability.immutable are not dumped 147 | - Mutability.write_only are dumped 148 | - Mutability.read_only are not dumped 149 | """ 150 | assert mut_resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST) == { 151 | "schemas": ["org:example:MutResource"], 152 | "readWrite": "x", 153 | "writeOnly": "x", 154 | "immutable": "x", 155 | } 156 | 157 | 158 | def test_dump_search_request(mut_resource): 159 | """Test query building for resource query request. 160 | 161 | Attributes marked as: 162 | - Mutability.read_write are dumped 163 | - Mutability.immutable are dumped 164 | - Mutability.write_only are not dumped 165 | - Mutability.read_only are dumped 166 | """ 167 | assert mut_resource.model_dump(scim_ctx=Context.RESOURCE_QUERY_REQUEST) == { 168 | "schemas": ["org:example:MutResource"], 169 | "id": "id", 170 | "readOnly": "x", 171 | "readWrite": "x", 172 | "immutable": "x", 173 | } 174 | 175 | 176 | def test_dump_default_response(ret_resource): 177 | """When no scim context is passed, every attributes are dumped.""" 178 | assert ret_resource.model_dump() == { 179 | "schemas": ["org:example:SupRetResource"], 180 | "id": "id", 181 | "alwaysReturned": "x", 182 | "neverReturned": "x", 183 | "defaultReturned": "x", 184 | "requestReturned": "x", 185 | "sub": { 186 | "alwaysReturned": "x", 187 | "neverReturned": "x", 188 | "defaultReturned": "x", 189 | "requestReturned": "x", 190 | }, 191 | } 192 | 193 | 194 | @pytest.mark.parametrize( 195 | "context", 196 | [ 197 | Context.RESOURCE_CREATION_RESPONSE, 198 | Context.RESOURCE_QUERY_RESPONSE, 199 | Context.RESOURCE_REPLACEMENT_RESPONSE, 200 | Context.SEARCH_RESPONSE, 201 | ], 202 | ) 203 | def test_dump_response(context, ret_resource): 204 | """Test context for responses. 205 | 206 | Attributes marked as: 207 | - Returned.always are always dumped 208 | - Returned.never are never dumped 209 | - Returned.default are dumped unless excluded 210 | - Returned.request are dumped only if included 211 | 212 | Including attributes with 'attributes=' replace the whole default set. 213 | """ 214 | assert ret_resource.model_dump(scim_ctx=context) == { 215 | "schemas": ["org:example:SupRetResource"], 216 | "id": "id", 217 | "alwaysReturned": "x", 218 | "defaultReturned": "x", 219 | "sub": { 220 | "alwaysReturned": "x", 221 | "defaultReturned": "x", 222 | }, 223 | } 224 | 225 | assert ret_resource.model_dump(scim_ctx=context, attributes={"alwaysReturned"}) == { 226 | "schemas": ["org:example:SupRetResource"], 227 | "id": "id", 228 | "alwaysReturned": "x", 229 | } 230 | 231 | assert ret_resource.model_dump(scim_ctx=context, attributes={"neverReturned"}) == { 232 | "schemas": ["org:example:SupRetResource"], 233 | "id": "id", 234 | "alwaysReturned": "x", 235 | } 236 | 237 | assert ret_resource.model_dump( 238 | scim_ctx=context, attributes={"defaultReturned"} 239 | ) == { 240 | "schemas": ["org:example:SupRetResource"], 241 | "id": "id", 242 | "alwaysReturned": "x", 243 | "defaultReturned": "x", 244 | } 245 | 246 | assert ret_resource.model_dump(scim_ctx=context, attributes={"sub"}) == { 247 | "schemas": ["org:example:SupRetResource"], 248 | "id": "id", 249 | "alwaysReturned": "x", 250 | "sub": { 251 | "alwaysReturned": "x", 252 | }, 253 | } 254 | 255 | assert ret_resource.model_dump( 256 | scim_ctx=context, attributes={"sub.defaultReturned"} 257 | ) == { 258 | "schemas": ["org:example:SupRetResource"], 259 | "id": "id", 260 | "alwaysReturned": "x", 261 | "sub": { 262 | "alwaysReturned": "x", 263 | "defaultReturned": "x", 264 | }, 265 | } 266 | 267 | assert ret_resource.model_dump( 268 | scim_ctx=context, attributes={"requestReturned"} 269 | ) == { 270 | "schemas": ["org:example:SupRetResource"], 271 | "id": "id", 272 | "alwaysReturned": "x", 273 | "requestReturned": "x", 274 | } 275 | 276 | assert ret_resource.model_dump( 277 | scim_ctx=context, 278 | attributes={"defaultReturned", "requestReturned"}, 279 | ) == { 280 | "schemas": ["org:example:SupRetResource"], 281 | "id": "id", 282 | "alwaysReturned": "x", 283 | "defaultReturned": "x", 284 | "requestReturned": "x", 285 | } 286 | 287 | assert ret_resource.model_dump( 288 | scim_ctx=context, excluded_attributes={"alwaysReturned"} 289 | ) == { 290 | "schemas": ["org:example:SupRetResource"], 291 | "id": "id", 292 | "alwaysReturned": "x", 293 | "defaultReturned": "x", 294 | "sub": { 295 | "alwaysReturned": "x", 296 | "defaultReturned": "x", 297 | }, 298 | } 299 | 300 | assert ret_resource.model_dump( 301 | scim_ctx=context, excluded_attributes={"neverReturned"} 302 | ) == { 303 | "schemas": ["org:example:SupRetResource"], 304 | "id": "id", 305 | "alwaysReturned": "x", 306 | "defaultReturned": "x", 307 | "sub": { 308 | "alwaysReturned": "x", 309 | "defaultReturned": "x", 310 | }, 311 | } 312 | 313 | assert ret_resource.model_dump( 314 | scim_ctx=context, excluded_attributes={"defaultReturned"} 315 | ) == { 316 | "schemas": ["org:example:SupRetResource"], 317 | "id": "id", 318 | "alwaysReturned": "x", 319 | "sub": { 320 | "alwaysReturned": "x", 321 | "defaultReturned": "x", 322 | }, 323 | } 324 | 325 | assert ret_resource.model_dump( 326 | scim_ctx=context, excluded_attributes={"requestReturned"} 327 | ) == { 328 | "schemas": ["org:example:SupRetResource"], 329 | "id": "id", 330 | "alwaysReturned": "x", 331 | "defaultReturned": "x", 332 | "sub": { 333 | "alwaysReturned": "x", 334 | "defaultReturned": "x", 335 | }, 336 | } 337 | 338 | assert ret_resource.model_dump( 339 | scim_ctx=context, 340 | excluded_attributes={"defaultReturned", "requestReturned"}, 341 | ) == { 342 | "schemas": ["org:example:SupRetResource"], 343 | "id": "id", 344 | "alwaysReturned": "x", 345 | "sub": { 346 | "alwaysReturned": "x", 347 | "defaultReturned": "x", 348 | }, 349 | } 350 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Union 3 | 4 | from scim2_models import BulkRequest 5 | from scim2_models import BulkResponse 6 | from scim2_models import EnterpriseUser 7 | from scim2_models import Error 8 | from scim2_models import Group 9 | from scim2_models import ListResponse 10 | from scim2_models import PatchOp 11 | from scim2_models import Resource 12 | from scim2_models import ResourceType 13 | from scim2_models import Schema 14 | from scim2_models import SearchRequest 15 | from scim2_models import ServiceProviderConfig 16 | from scim2_models import User 17 | 18 | 19 | def test_parse_and_serialize_examples(load_sample): 20 | samples = list(os.walk("samples"))[0][2] 21 | models = { 22 | "user": User, 23 | "enterprise_user": User[EnterpriseUser], 24 | "group": Group, 25 | "schema": Schema, 26 | "resource_type": ResourceType, 27 | "service_provider_configuration": ServiceProviderConfig, 28 | "list_response": ListResponse[ 29 | Union[User[EnterpriseUser], Group, Schema, ResourceType] 30 | ], 31 | "patch_op": PatchOp, 32 | "bulk_request": BulkRequest, 33 | "bulk_response": BulkResponse, 34 | "search_request": SearchRequest, 35 | "error": Error, 36 | } 37 | 38 | for sample in samples: 39 | model_name = sample.replace(".json", "").split("-")[2] 40 | model = models[model_name] 41 | 42 | skipped = [ 43 | # resources without schemas are not yet supported 44 | # https://github.com/python-scim/scim2-models/issues/20 45 | "rfc7644-3.4.2-list_response-partial_attributes.json", 46 | "rfc7644-3.4.3-list_response-post_query.json", 47 | # BulkOperation.data PatchOperation.value should be of type resource 48 | # instead of Any, so serialization case would be respected. 49 | "rfc7644-3.7.1-bulk_request-circular_conflict.json", 50 | "rfc7644-3.7.2-bulk_request-enterprise_user.json", 51 | "rfc7644-3.7.2-bulk_request-temporary_identifier.json", 52 | "rfc7644-3.7.2-bulk_response-temporary_identifier.json", 53 | "rfc7644-3.7.3-bulk_request-multiple_operations.json", 54 | "rfc7644-3.7.3-bulk_response-error_invalid_syntax.json", 55 | "rfc7644-3.7.3-bulk_response-multiple_errors.json", 56 | "rfc7644-3.7.3-bulk_response-multiple_operations.json", 57 | "rfc7644-3.5.2.1-patch_op-add_emails.json", 58 | "rfc7644-3.5.2.1-patch_op-add_members.json", 59 | "rfc7644-3.5.2.2-patch_op-remove_all_members.json", 60 | "rfc7644-3.5.2.2-patch_op-remove_and_add_one_member.json", 61 | "rfc7644-3.5.2.2-patch_op-remove_multi_complex_value.json", 62 | "rfc7644-3.5.2.2-patch_op-remove_one_member.json", 63 | "rfc7644-3.5.2.3-patch_op-replace_all_email_values.json", 64 | "rfc7644-3.5.2.3-patch_op-replace_all_members.json", 65 | "rfc7644-3.5.2.3-patch_op-replace_street_address.json", 66 | "rfc7644-3.5.2.3-patch_op-replace_user_work_address.json", 67 | ] 68 | if sample in skipped: 69 | continue 70 | 71 | payload = load_sample(sample) 72 | obj = model.model_validate(payload) 73 | assert obj.model_dump(exclude_unset=True) == payload 74 | 75 | 76 | def test_get_resource_by_schema(): 77 | resource_types = [Group, User[EnterpriseUser]] 78 | assert ( 79 | Resource.get_by_schema( 80 | resource_types, "urn:ietf:params:scim:schemas:core:2.0:Group" 81 | ) 82 | == Group 83 | ) 84 | assert ( 85 | Resource.get_by_schema( 86 | resource_types, "urn:ietf:params:scim:schemas:core:2.0:User" 87 | ) 88 | == User[EnterpriseUser] 89 | ) 90 | assert ( 91 | Resource.get_by_schema( 92 | resource_types, 93 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", 94 | with_extensions=False, 95 | ) 96 | is None 97 | ) 98 | assert ( 99 | Resource.get_by_schema( 100 | resource_types, 101 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", 102 | ) 103 | == EnterpriseUser 104 | ) 105 | 106 | 107 | def test_get_resource_by_payload(): 108 | resource_types = [Group, User[EnterpriseUser]] 109 | payload = {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"]} 110 | assert Resource.get_by_payload(resource_types, payload) == Group 111 | 112 | payload = {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]} 113 | assert Resource.get_by_payload(resource_types, payload) == User[EnterpriseUser] 114 | 115 | payload = { 116 | "schemas": ["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] 117 | } 118 | assert ( 119 | Resource.get_by_payload( 120 | resource_types, 121 | payload, 122 | with_extensions=False, 123 | ) 124 | is None 125 | ) 126 | 127 | payload = { 128 | "schemas": ["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] 129 | } 130 | assert Resource.get_by_payload(resource_types, payload) == EnterpriseUser 131 | 132 | payload = {"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]} 133 | assert ( 134 | Resource.get_by_payload([ListResponse[User]], payload, with_extensions=False) 135 | == ListResponse[User] 136 | ) 137 | 138 | payload = {"foo": "bar"} 139 | assert Resource.get_by_payload(resource_types, payload) is None 140 | 141 | 142 | def test_everything_is_optional(): 143 | """Test that all attributes are optional on pre-defined models.""" 144 | models = [ 145 | User, 146 | EnterpriseUser, 147 | Group, 148 | Schema, 149 | ResourceType, 150 | ServiceProviderConfig, 151 | ListResponse[User], 152 | PatchOp, 153 | BulkRequest, 154 | BulkResponse, 155 | SearchRequest, 156 | Error, 157 | ] 158 | for model in models: 159 | model() 160 | -------------------------------------------------------------------------------- /tests/test_patch_op.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import ValidationError 3 | 4 | from scim2_models import PatchOp 5 | from scim2_models import PatchOperation 6 | 7 | 8 | def test_validate_patchop_case_insensitivith(): 9 | """Validate that a patch operation's Op declaration is case-insensitive.""" 10 | assert PatchOp.model_validate( 11 | { 12 | "operations": [ 13 | {"op": "Replace", "path": "userName", "value": "Rivard"}, 14 | {"op": "ADD", "path": "userName", "value": "Rivard"}, 15 | {"op": "ReMove", "path": "userName", "value": "Rivard"}, 16 | ], 17 | }, 18 | ) == PatchOp( 19 | operations=[ 20 | PatchOperation( 21 | op=PatchOperation.Op.replace_, path="userName", value="Rivard" 22 | ), 23 | PatchOperation(op=PatchOperation.Op.add, path="userName", value="Rivard"), 24 | PatchOperation( 25 | op=PatchOperation.Op.remove, path="userName", value="Rivard" 26 | ), 27 | ] 28 | ) 29 | with pytest.raises( 30 | ValidationError, 31 | match="1 validation error for PatchOp", 32 | ): 33 | PatchOp.model_validate( 34 | { 35 | "operations": [{"op": 42, "path": "userName", "value": "Rivard"}], 36 | }, 37 | ) 38 | -------------------------------------------------------------------------------- /tests/test_resource_type.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from typing import Union 3 | 4 | from scim2_models import EnterpriseUser 5 | from scim2_models import Extension 6 | from scim2_models import Reference 7 | from scim2_models import Required 8 | from scim2_models import ResourceType 9 | from scim2_models import User 10 | 11 | 12 | def test_user_resource_type(load_sample): 13 | payload = load_sample("rfc7643-8.6-resource_type-user.json") 14 | obj = ResourceType.model_validate(payload) 15 | 16 | assert obj.schemas == ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"] 17 | assert obj.id == "User" 18 | assert obj.name == "User" 19 | assert obj.endpoint == "/Users" 20 | assert obj.description == "User Account" 21 | assert obj.schema_ == Reference("urn:ietf:params:scim:schemas:core:2.0:User") 22 | assert obj.schema_extensions[0].schema_ == Reference( 23 | "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" 24 | ) 25 | assert obj.schema_extensions[0].required is True 26 | assert obj.meta.location == "https://example.com/v2/ResourceTypes/User" 27 | assert obj.meta.resource_type == "ResourceType" 28 | 29 | assert obj.model_dump(exclude_unset=True) == payload 30 | 31 | 32 | def test_group_resource_type(load_sample): 33 | payload = load_sample("rfc7643-8.6-resource_type-group.json") 34 | obj = ResourceType.model_validate(payload) 35 | assert obj.schemas == ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"] 36 | assert obj.id == "Group" 37 | assert obj.name == "Group" 38 | assert obj.endpoint == "/Groups" 39 | assert obj.description == "Group" 40 | assert obj.schema_ == Reference("urn:ietf:params:scim:schemas:core:2.0:Group") 41 | assert obj.meta.location == "https://example.com/v2/ResourceTypes/Group" 42 | assert obj.meta.resource_type == "ResourceType" 43 | 44 | assert obj.model_dump(exclude_unset=True) == payload 45 | 46 | 47 | def test_from_simple_resource(): 48 | user_rt = ResourceType.from_resource(User) 49 | assert user_rt.id == "User" 50 | assert user_rt.name == "User" 51 | assert user_rt.description == "User" 52 | assert user_rt.endpoint == "/Users" 53 | assert user_rt.schema_ == "urn:ietf:params:scim:schemas:core:2.0:User" 54 | assert not user_rt.schema_extensions 55 | 56 | 57 | def test_from_resource_with_extensions(): 58 | enterprise_user_rt = ResourceType.from_resource(User[EnterpriseUser]) 59 | assert enterprise_user_rt.id == "User" 60 | assert enterprise_user_rt.name == "User" 61 | assert enterprise_user_rt.description == "User" 62 | assert enterprise_user_rt.endpoint == "/Users" 63 | assert enterprise_user_rt.schema_ == "urn:ietf:params:scim:schemas:core:2.0:User" 64 | assert ( 65 | enterprise_user_rt.schema_extensions[0].schema_ 66 | == "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" 67 | ) 68 | assert not enterprise_user_rt.schema_extensions[0].required 69 | 70 | 71 | def test_from_resource_with_mulitple_extensions(): 72 | class TestExtension(Extension): 73 | schemas: Annotated[list[str], Required.true] = [ 74 | "urn:ietf:params:scim:schemas:extension:Test:1.0:User" 75 | ] 76 | 77 | test: Union[str, None] = None 78 | test2: Union[list[str], None] = None 79 | 80 | enterprise_user_rt = ResourceType.from_resource( 81 | User[Union[EnterpriseUser, TestExtension]] 82 | ) 83 | assert enterprise_user_rt.id == "User" 84 | assert enterprise_user_rt.name == "User" 85 | assert enterprise_user_rt.description == "User" 86 | assert enterprise_user_rt.endpoint == "/Users" 87 | assert enterprise_user_rt.schema_ == "urn:ietf:params:scim:schemas:core:2.0:User" 88 | assert ( 89 | enterprise_user_rt.schema_extensions[0].schema_ 90 | == "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" 91 | ) 92 | assert not enterprise_user_rt.schema_extensions[0].required 93 | assert ( 94 | enterprise_user_rt.schema_extensions[1].schema_ 95 | == "urn:ietf:params:scim:schemas:extension:Test:1.0:User" 96 | ) 97 | assert not enterprise_user_rt.schema_extensions[1].required 98 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import ValidationError 3 | 4 | from scim2_models import Attribute 5 | from scim2_models import Mutability 6 | from scim2_models import Returned 7 | from scim2_models import Schema 8 | from scim2_models import Uniqueness 9 | 10 | 11 | def test_group_schema(load_sample): 12 | payload = load_sample("rfc7643-8.7.1-schema-group.json") 13 | obj = Schema.model_validate(payload) 14 | 15 | assert obj.id == "urn:ietf:params:scim:schemas:core:2.0:Group" 16 | assert obj.name == "Group" 17 | assert obj.description == "Group" 18 | assert obj.attributes[0].name == "displayName" 19 | assert obj.attributes[0].type == Attribute.Type.string 20 | assert not obj.attributes[0].multi_valued 21 | assert obj.attributes[0].description == ( 22 | "A human-readable name for the Group. REQUIRED." 23 | ) 24 | assert not obj.attributes[0].required 25 | assert not obj.attributes[0].case_exact 26 | assert obj.attributes[0].mutability == Mutability.read_write 27 | assert obj.attributes[0].returned == Returned.default 28 | assert obj.attributes[0].uniqueness == Uniqueness.none 29 | assert obj.attributes[1].name == "members" 30 | assert obj.attributes[1].type == Attribute.Type.complex 31 | assert obj.attributes[1].multi_valued 32 | assert obj.attributes[1].description == "A list of members of the Group." 33 | assert not obj.attributes[1].required 34 | assert obj.attributes[1].sub_attributes[0].name == "value" 35 | assert obj.attributes[1].sub_attributes[0].type == Attribute.Type.string 36 | assert not obj.attributes[1].sub_attributes[0].multi_valued 37 | assert ( 38 | obj.attributes[1].sub_attributes[0].description 39 | == "Identifier of the member of this Group." 40 | ) 41 | assert not obj.attributes[1].sub_attributes[0].required 42 | assert not obj.attributes[1].sub_attributes[0].case_exact 43 | assert obj.attributes[1].sub_attributes[0].mutability == Mutability.immutable 44 | assert obj.attributes[1].sub_attributes[0].returned == Returned.default 45 | assert obj.attributes[1].sub_attributes[0].uniqueness == Uniqueness.none 46 | assert obj.attributes[1].sub_attributes[1].name == "$ref" 47 | assert obj.attributes[1].sub_attributes[1].type == Attribute.Type.reference 48 | assert obj.attributes[1].sub_attributes[1].reference_types == ["User", "Group"] 49 | assert not obj.attributes[1].sub_attributes[1].multi_valued 50 | assert obj.attributes[1].sub_attributes[1].description == ( 51 | "The URI corresponding to a SCIM resource that is a member of this Group." 52 | ) 53 | assert not obj.attributes[1].sub_attributes[1].required 54 | assert not obj.attributes[1].sub_attributes[1].case_exact 55 | assert obj.attributes[1].sub_attributes[1].mutability == Mutability.immutable 56 | assert obj.attributes[1].sub_attributes[1].returned == Returned.default 57 | assert obj.attributes[1].sub_attributes[1].uniqueness == Uniqueness.none 58 | assert obj.attributes[1].sub_attributes[2].name == "type" 59 | assert obj.attributes[1].sub_attributes[2].type == Attribute.Type.string 60 | assert not obj.attributes[1].sub_attributes[2].multi_valued 61 | assert obj.attributes[1].sub_attributes[2].description == ( 62 | "A label indicating the type of resource, e.g., 'User' or 'Group'." 63 | ) 64 | assert not obj.attributes[1].sub_attributes[2].required 65 | assert not obj.attributes[1].sub_attributes[2].case_exact 66 | assert obj.attributes[1].sub_attributes[2].canonical_values == ["User", "Group"] 67 | assert obj.attributes[1].sub_attributes[2].mutability == Mutability.immutable 68 | assert obj.attributes[1].sub_attributes[2].returned == Returned.default 69 | assert obj.attributes[1].sub_attributes[2].uniqueness == Uniqueness.none 70 | assert obj.attributes[1].mutability == Mutability.read_write 71 | assert obj.attributes[1].returned == Returned.default 72 | assert obj.meta.resource_type == "Schema" 73 | assert ( 74 | obj.meta.location == "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group" 75 | ) 76 | 77 | assert obj.model_dump(exclude_unset=True) == payload 78 | 79 | 80 | def test_uri_ids(): 81 | """Test that schema ids are URI, as defined in RFC7643 §7. 82 | 83 | https://datatracker.ietf.org/doc/html/rfc7643#section-7 84 | """ 85 | Schema(id="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User") 86 | with pytest.raises(ValidationError): 87 | Schema(id="invalid\nuri") 88 | 89 | 90 | def test_get_schema_attribute(load_sample): 91 | """Test the Schema.get_attribute method.""" 92 | payload = load_sample("rfc7643-8.7.1-schema-user.json") 93 | schema = Schema.model_validate(payload) 94 | assert schema.get_attribute("invalid") is None 95 | with pytest.raises(KeyError): 96 | schema["invalid"] 97 | 98 | assert schema.attributes[0].name == "userName" 99 | assert schema.attributes[0].mutability == Mutability.read_write 100 | 101 | schema.get_attribute("userName").mutability = Mutability.read_only 102 | assert schema.attributes[0].mutability == Mutability.read_only 103 | 104 | schema["userName"].mutability = Mutability.read_write 105 | assert schema.attributes[0].mutability == Mutability.read_write 106 | 107 | 108 | def test_get_attribute_attribute(load_sample): 109 | """Test the Schema.get_attribute method.""" 110 | payload = load_sample("rfc7643-8.7.1-schema-group.json") 111 | schema = Schema.model_validate(payload) 112 | attribute = schema.get_attribute("members") 113 | 114 | assert attribute.get_attribute("invalid") is None 115 | with pytest.raises(KeyError): 116 | attribute["invalid"] 117 | 118 | assert attribute.sub_attributes[0].name == "value" 119 | assert attribute.sub_attributes[0].mutability == Mutability.immutable 120 | 121 | attribute.get_attribute("value").mutability = Mutability.read_only 122 | assert attribute.sub_attributes[0].mutability == Mutability.read_only 123 | 124 | attribute["value"].mutability = Mutability.read_write 125 | assert attribute.sub_attributes[0].mutability == Mutability.read_write 126 | -------------------------------------------------------------------------------- /tests/test_search_request.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import ValidationError 3 | 4 | from scim2_models.rfc7644.search_request import SearchRequest 5 | 6 | 7 | def test_search_request(): 8 | SearchRequest( 9 | attributes=["userName", "displayName"], 10 | filter='userName Eq "john"', 11 | sort_by="userName", 12 | sort_order=SearchRequest.SortOrder.ascending, 13 | start_index=1, 14 | count=10, 15 | ) 16 | 17 | SearchRequest( 18 | excluded_attributes=["timezone", "phoneNumbers"], 19 | filter='userName Eq "john"', 20 | sort_by="userName", 21 | sort_order=SearchRequest.SortOrder.ascending, 22 | start_index=1, 23 | count=10, 24 | ) 25 | 26 | 27 | def test_start_index_floor(): 28 | """Test that startIndex values less than 0 are interpreted as 0. 29 | 30 | https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4 31 | 32 | A value less than 1 SHALL be interpreted as 1. 33 | """ 34 | sr = SearchRequest(start_index=100) 35 | assert sr.start_index == 100 36 | 37 | sr = SearchRequest(start_index=0) 38 | assert sr.start_index == 1 39 | 40 | 41 | def test_count_floor(): 42 | """Test that count values less than 1 are interpreted as 1. 43 | 44 | https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4 45 | 46 | A negative value SHALL be interpreted as 0. 47 | """ 48 | sr = SearchRequest(count=100) 49 | assert sr.count == 100 50 | 51 | sr = SearchRequest(count=-1) 52 | assert sr.count == 0 53 | 54 | 55 | def test_attributes_or_excluded_attributes(): 56 | """Test that a validation error is raised when both 'attributes' and 'excludedAttributes' are filled at the same time. 57 | 58 | https://datatracker.ietf.org/doc/html/rfc7644#section-3.9 59 | 60 | Clients MAY request a partial resource representation on any 61 | operation that returns a resource within the response by specifying 62 | either of the mutually exclusive URL query parameters "attributes" or 63 | "excludedAttributes"... 64 | """ 65 | payload = { 66 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], 67 | "attributes": ["userName"], 68 | "excludedAttributes": [ 69 | "displayName", 70 | ], 71 | } 72 | with pytest.raises(ValidationError): 73 | SearchRequest.model_validate(payload) 74 | 75 | 76 | def test_index_0_properties(): 77 | req = SearchRequest(start_index=1, count=10) 78 | assert req.start_index_0 == 0 79 | assert req.stop_index_0 == 10 80 | -------------------------------------------------------------------------------- /tests/test_service_provider_configuration.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from scim2_models import AuthenticationScheme 4 | from scim2_models import Reference 5 | from scim2_models import ServiceProviderConfig 6 | 7 | 8 | def test_service_provider_configuration(load_sample): 9 | """Test creating an object representing the SPC example found in RFC7643.""" 10 | payload = load_sample("rfc7643-8.5-service_provider_configuration.json") 11 | obj = ServiceProviderConfig.model_validate(payload) 12 | 13 | assert obj.schemas == [ 14 | "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" 15 | ] 16 | assert obj.documentation_uri == Reference("http://example.com/help/scim.html") 17 | assert obj.patch.supported is True 18 | assert obj.bulk.supported is True 19 | assert obj.bulk.max_operations == 1000 20 | assert obj.bulk.max_payload_size == 1048576 21 | assert obj.filter.supported is True 22 | assert obj.filter.max_results == 200 23 | assert obj.change_password.supported is True 24 | assert obj.sort.supported is True 25 | assert obj.etag.supported is True 26 | assert obj.authentication_schemes[0].name == "OAuth Bearer Token" 27 | assert ( 28 | obj.authentication_schemes[0].description 29 | == "Authentication scheme using the OAuth Bearer Token Standard" 30 | ) 31 | assert obj.authentication_schemes[0].spec_uri == Reference( 32 | "http://www.rfc-editor.org/info/rfc6750" 33 | ) 34 | assert obj.authentication_schemes[0].documentation_uri == Reference( 35 | "http://example.com/help/oauth.html" 36 | ) 37 | assert ( 38 | obj.authentication_schemes[0].type == AuthenticationScheme.Type.oauthbearertoken 39 | ) 40 | assert obj.authentication_schemes[0].primary is True 41 | 42 | assert obj.authentication_schemes[1].name == "HTTP Basic" 43 | assert ( 44 | obj.authentication_schemes[1].description 45 | == "Authentication scheme using the HTTP Basic Standard" 46 | ) 47 | assert obj.authentication_schemes[1].spec_uri == Reference( 48 | "http://www.rfc-editor.org/info/rfc2617" 49 | ) 50 | assert obj.authentication_schemes[1].documentation_uri == Reference( 51 | "http://example.com/help/httpBasic.html" 52 | ) 53 | assert obj.authentication_schemes[1].type == AuthenticationScheme.Type.httpbasic 54 | assert obj.meta.location == "https://example.com/v2/ServiceProviderConfig" 55 | assert obj.meta.resource_type == "ServiceProviderConfig" 56 | assert obj.meta.created == datetime.datetime( 57 | 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc 58 | ) 59 | assert obj.meta.last_modified == datetime.datetime( 60 | 2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc 61 | ) 62 | assert obj.meta.version == 'W\\/"3694e05e9dff594"' 63 | 64 | assert obj.model_dump() == payload 65 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from scim2_models import Address 4 | from scim2_models import Email 5 | from scim2_models import Im 6 | from scim2_models import PhoneNumber 7 | from scim2_models import Photo 8 | from scim2_models import Reference 9 | from scim2_models import User 10 | 11 | 12 | def test_minimal_user(load_sample): 13 | """Test creating an object representing the minimal user example of RFC7643.""" 14 | payload = load_sample("rfc7643-8.1-user-minimal.json") 15 | obj = User.model_validate(payload) 16 | 17 | assert obj.schemas == ["urn:ietf:params:scim:schemas:core:2.0:User"] 18 | assert obj.id == "2819c223-7f76-453a-919d-413861904646" 19 | assert obj.user_name == "bjensen@example.com" 20 | assert obj.meta.resource_type == "User" 21 | assert obj.meta.created == datetime.datetime( 22 | 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc 23 | ) 24 | assert obj.meta.last_modified == datetime.datetime( 25 | 2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc 26 | ) 27 | assert obj.meta.version == 'W\\/"3694e05e9dff590"' 28 | assert ( 29 | obj.meta.location 30 | == "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" 31 | ) 32 | 33 | 34 | def test_full_user(load_sample): 35 | """Test creating an object representing the full user example of RFC7643.""" 36 | payload = load_sample("rfc7643-8.2-user-full.json") 37 | obj = User.model_validate(payload) 38 | 39 | assert obj.schemas == ["urn:ietf:params:scim:schemas:core:2.0:User"] 40 | assert obj.id == "2819c223-7f76-453a-919d-413861904646" 41 | assert obj.external_id == "701984" 42 | assert obj.user_name == "bjensen@example.com" 43 | assert obj.name 44 | assert obj.name.formatted == "Ms. Barbara J Jensen, III" 45 | assert obj.name.family_name == "Jensen" 46 | assert obj.name.given_name == "Barbara" 47 | assert obj.name.middle_name == "Jane" 48 | assert obj.name.honorific_prefix == "Ms." 49 | assert obj.name.honorific_suffix == "III" 50 | assert obj.display_name == "Babs Jensen" 51 | assert obj.nick_name == "Babs" 52 | assert obj.profile_url == Reference("https://login.example.com/bjensen") 53 | assert obj.emails[0].value == "bjensen@example.com" 54 | assert obj.emails[0].type == Email.Type.work 55 | assert obj.emails[0].primary is True 56 | assert obj.emails[1].value == "babs@jensen.org" 57 | assert obj.emails[1].type == Email.Type.home 58 | assert obj.addresses[0].type == Address.Type.work 59 | assert obj.addresses[0].street_address == "100 Universal City Plaza" 60 | assert obj.addresses[0].locality == "Hollywood" 61 | assert obj.addresses[0].region == "CA" 62 | assert obj.addresses[0].postal_code == "91608" 63 | assert obj.addresses[0].country == "USA" 64 | assert ( 65 | obj.addresses[0].formatted 66 | == "100 Universal City Plaza\nHollywood, CA 91608 USA" 67 | ) 68 | assert obj.addresses[0].primary is True 69 | assert obj.addresses[1].type == Address.Type.home 70 | assert obj.addresses[1].street_address == "456 Hollywood Blvd" 71 | assert obj.addresses[1].locality == "Hollywood" 72 | assert obj.addresses[1].region == "CA" 73 | assert obj.addresses[1].postal_code == "91608" 74 | assert obj.addresses[1].country == "USA" 75 | assert obj.addresses[1].formatted == "456 Hollywood Blvd\nHollywood, CA 91608 USA" 76 | assert obj.phone_numbers[0].value == "555-555-5555" 77 | assert obj.phone_numbers[0].type == PhoneNumber.Type.work 78 | assert obj.phone_numbers[1].value == "555-555-4444" 79 | assert obj.phone_numbers[1].type == PhoneNumber.Type.mobile 80 | assert obj.ims[0].value == "someaimhandle" 81 | assert obj.ims[0].type == Im.Type.aim 82 | assert obj.photos[0].value == Reference( 83 | "https://photos.example.com/profilephoto/72930000000Ccne/F" 84 | ) 85 | assert obj.photos[0].type == Photo.Type.photo 86 | assert obj.photos[1].value == Reference( 87 | "https://photos.example.com/profilephoto/72930000000Ccne/T" 88 | ) 89 | assert obj.photos[1].type == Photo.Type.thumbnail 90 | assert obj.user_type == "Employee" 91 | assert obj.title == "Tour Guide" 92 | assert obj.preferred_language == "en-US" 93 | assert obj.locale == "en-US" 94 | assert obj.timezone == "America/Los_Angeles" 95 | assert obj.active is True 96 | assert obj.password == "t1meMa$heen" 97 | assert obj.groups[0].value == "e9e30dba-f08f-4109-8486-d5c6a331660a" 98 | assert obj.groups[0].ref == Reference( 99 | "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a" 100 | ) 101 | assert obj.groups[0].display == "Tour Guides" 102 | assert obj.groups[1].value == "fc348aa8-3835-40eb-a20b-c726e15c55b5" 103 | assert obj.groups[1].ref == Reference( 104 | "https://example.com/v2/Groups/fc348aa8-3835-40eb-a20b-c726e15c55b5" 105 | ) 106 | assert obj.groups[1].display == "Employees" 107 | assert obj.groups[2].value == "71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7" 108 | assert obj.groups[2].ref == Reference( 109 | "https://example.com/v2/Groups/71ddacd2-a8e7-49b8-a5db-ae50d0a5bfd7" 110 | ) 111 | assert obj.groups[2].display == "US Employees" 112 | assert obj.x509_certificates[0].value == ( 113 | b"0\x82\x03C0\x82\x02\xac\xa0\x03\x02\x01\x02\x02\x02\x10\x000\r\x06\t*\x86H\x86\xf7\r\x01\x01\x05\x05\x000N1\x0b0\t\x06\x03U\x04\x06\x13\x02US1\x130\x11\x06\x03U\x04\x08\x0c\nCalifornia1\x140\x12\x06\x03U\x04\n\x0c\x0bexample.com1\x140\x12\x06\x03U\x04\x03\x0c\x0bexample.com0\x1e\x17\r111022062431Z\x17\r121004062431Z0\x7f1\x0b0\t\x06\x03U\x04\x06\x13\x02US1\x130\x11\x06\x03U\x04\x08\x0c\nCalifornia1\x140\x12\x06\x03U\x04\n\x0c\x0bexample.com1!0\x1f\x06\x03U\x04\x03\x0c\x18Ms. Barbara J Jensen III1\"0 \x06\t*\x86H\x86\xf7\r\x01\t\x01\x16\x13bjensen@example.com0\x82\x01\"0\r\x06\t*\x86H\x86\xf7\r\x01\x01\x01\x05\x00\x03\x82\x01\x0f\x000\x82\x01\n\x02\x82\x01\x01\x00\xec\xaa\xfe\r\xc7l\xfc\x949\x1b\x07\xa3$W\x01 \xfe\xbc\xd9}\xf1\xa68\xac\xe7\xa0\n\xd3f\xdc\xd4R\xe0\xcd\xd2\xc8\xf1\xab\xa8G\xe7\x02\xf7\xf5k\x87\x9bz\xe8y\x10 \xe7@\xe2\xe9\xc7\x87@\x1ag\x8cK\xe4\xf8Umr\x0f0\x1eo\x00\xf2\xa9\xcf>b=(\xbc\xc4\xef\x12/\xb2;H8\\\x05Ra\xa9Z\xab\xdcx%\x94A\xbaP)Cts\xbb\x9eB\xee\xc1z\xbc\x1d\xc2\x12*F\xd3\xe5aSU\xf1Y\xce'O\x97\xc1\xd9\xec8W\x91\x92\x11\xb4\x9c\x01\xc1\xea\xb8n\xf9\xb7\x84\xcdN\xb3\xb5\x10\x1fNYK\xa7\x15\x0e\x0c\x1e(\xdc\x1d,\xba\xd3\xe7X\xa4I\x01\xb7\r\x8a\xe5\xf9\xfb{\xf3U\x10D\x8a\xb1\x83\n\x82}q.\x0e(\xa7>\x1d$\x92\xf2\"\xe68\xafV\xedY\xf5\x08\xb0\x95\x81\x1dE\xb2\xc9\xe32P\x06`\xebHd\xccb~%E\xcd\x87\x80s\xb8\x0e\xa0\x7fns\x15\xa2\xb1\xc1\xd0 \xba\x01]P\xa0R\xb8\x18\xf7\xb5/\x02\x03\x01\x00\x01\xa3{0y0\t\x06\x03U\x1d\x13\x04\x020\x000,\x06\t`\x86H\x01\x86\xf8B\x01\r\x04\x1f\x16\x1dOpenSSL Generated Certificate0\x1d\x06\x03U\x1d\x0e\x04\x16\x04\x14\xf2\x90\xf4SK\xecd\x8b\x1a\x03^\xa5/\xc1'\xf1\xbct\x17\xf80\x1f\x06\x03U\x1d#\x04\x180\x16\x80\x14tg\x8a\x8a\xd7\x1a\x17\xb8'\xce\xc3p\x0f\x1e\xf4\xf2J\x9aV\xdd0\r\x06\t*\x86H\x86\xf7\r\x01\x01\x05\x05\x00\x03\x81\x81\x00\x03\xcdR\xb0Y\xceu\x82m6\x0eSr\xaf\xbf\x07!\x03\xac\x18'\xba\xcct\x8eZ\x14\x84\x1c\x8f0Ed\xa0\xc6w'\xb8\xf5f\x02<\xac\x06\xce\x90\xd9\xe0_\xcf\xa9)\xf4\xe2\x0f=Q\x0b\x8f\x9d\xc7\xca\x14\xe9\x96\xbe\xe0\xd2WR9K\xe4+\xd5\xe8\x11\x18o_\x90\x00Bp\x8a\xd4\xd5\xbf\x10\x7f\x03\xae\xe0\xe3o\xef\xce\x00-\xa1\x15\x1e\x0e\x8b\xf5\xf8ab\x05\x9f\x864_\xdc\x01\x82\x9c2\xd1\x9c\xae\xcd\xa2\xf7\xb6d$\xca" 114 | ) 115 | assert obj.meta.resource_type == "User" 116 | assert obj.meta.created == datetime.datetime( 117 | 2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc 118 | ) 119 | assert obj.meta.last_modified == datetime.datetime( 120 | 2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc 121 | ) 122 | assert obj.meta.version == 'W\\/"a330bc54f0671c9"' 123 | assert ( 124 | obj.meta.location 125 | == "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" 126 | ) 127 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from scim2_models.utils import to_camel 2 | 3 | 4 | def test_to_camel(): 5 | """Test camilization utility.""" 6 | assert to_camel("foo") == "foo" 7 | assert to_camel("Foo") == "foo" 8 | assert to_camel("fooBar") == "fooBar" 9 | assert to_camel("FooBar") == "fooBar" 10 | assert to_camel("foo_bar") == "fooBar" 11 | assert to_camel("Foo_bar") == "fooBar" 12 | assert to_camel("foo_Bar") == "fooBar" 13 | assert to_camel("Foo_Bar") == "fooBar" 14 | 15 | assert to_camel("$foo$") == "$foo$" 16 | --------------------------------------------------------------------------------