├── .coveragerc ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── cicd.yml ├── .gitignore ├── .isort.cfg ├── .readthedocs.yaml ├── CHANGES.txt ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── demo ├── README ├── app │ ├── __init__.py │ ├── adapters.py │ ├── constants.py │ ├── log_handlers.py │ ├── middleware.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_group_options_alter_user_managers_and_more.py │ │ └── __init__.py │ ├── models.py │ └── utils.py ├── manage.py ├── requirements.txt └── root │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── docs ├── Makefile ├── adapters.rst ├── conf.py ├── filters.rst ├── index.rst ├── make.bat ├── models.rst ├── requirements.txt ├── settings.rst ├── utils.rst └── views.rst ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── src └── django_scim │ ├── __init__.py │ ├── adapters.py │ ├── constants.py │ ├── exceptions.py │ ├── filters.py │ ├── middleware.py │ ├── models.py │ ├── schemas │ ├── README.rst │ ├── __init__.py │ ├── core │ │ ├── Group.json │ │ ├── ResourceType.json │ │ ├── Schema.json │ │ ├── ServiceProviderConfig.json │ │ └── User.json │ └── extension │ │ └── Enterprise-User.json │ ├── settings.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── tests ├── __init__.py ├── filters.py ├── hashers.py ├── models.py ├── settings.py ├── test_adapters.py ├── test_filters.py ├── test_middleware.py ├── test_models.py ├── test_utils.py ├── test_views.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = django_scim 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = __pycache__,.eggs/,.tox/ 3 | max-line-length = 120 4 | max-complexity = 7 5 | ignore = 6 | # blank line at end of file 7 | W391, 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: logston 7 | 8 | --- 9 | 10 | Please see our "Development Speed" note in the README.md of the project for expected response times. 11 | 12 | Thanks for the bug report! Here's some guidance to get you started on what we are looking for in terms of a bug report: 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior... 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Stacktrace** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please see our "Development Speed" note in the README.md of the project for expected response times. 11 | 12 | Thanks for the issue report! Here's some guidance to get you started on what we are looking for in terms of a report: 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: logston 7 | 8 | --- 9 | 10 | Please see our "Development Speed" note in the README of the project for expected response times. 11 | 12 | Thanks for the feature request! Here's some guidance to get you started on what we are looking for in terms of details: 13 | 14 | **Is your feature request related to a problem? Please describe.** 15 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 16 | 17 | **Describe the solution you'd like** 18 | A clear and concise description of what you want to happen. 19 | 20 | **Describe alternatives you've considered** 21 | A clear and concise description of any alternative solutions or features you've considered. 22 | 23 | **Additional context** 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: [push] 4 | 5 | env: 6 | PYTHONDONTWRITEBYTECODE: 1 7 | 8 | jobs: 9 | tests: 10 | name: Python ${{ matrix.python-version }} 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - run: pip install tox tox-gh-actions poetry==1.4.2 24 | - run: tox 25 | 26 | flake8: 27 | name: Flake8 28 | 29 | runs-on: ubuntu-latest 30 | 31 | container: 32 | image: python:3.11-bullseye 33 | 34 | steps: 35 | - uses: actions/checkout@v2 36 | - run: pip install tox poetry==1.4.2 37 | - run: tox -e flake8 38 | 39 | coverage: 40 | name: Coverage 41 | 42 | runs-on: ubuntu-latest 43 | 44 | container: 45 | image: python:3.11-bullseye 46 | 47 | steps: 48 | - uses: actions/checkout@v2 49 | - run: pip install tox poetry==1.4.2 50 | - run: tox -e coverage 51 | - uses: codecov/codecov-action@v5 52 | with: 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | fail_ci_if_error: true 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | 118 | # IntelliJ 119 | .idea/ 120 | 121 | # Vim 122 | *.swp 123 | .ctags 124 | 125 | # MacOSX 126 | .DS_Store 127 | 128 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | include_trailing_comma=True 3 | force_grid_wrap=0 4 | use_parentheses=True 5 | multi_line_output=3 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | 6 | tools: 7 | python: "3.11" 8 | 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | python: 13 | install: 14 | - requirements: docs/requirements.txt 15 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | CHANGE LOG 2 | ========== 3 | 4 | 0.19.1 5 | ------ 6 | 7 | - Allow sorting via get_queryset_post_processor. 8 | - Drop USE_L10N setting. 9 | - Switch to poetry-core. 10 | 11 | 0.19.0 12 | ------ 13 | 14 | - Add ability to override the Django User model used to look up a given User 15 | object. 16 | - Update scim2_filter_parser to 0.5.0. 17 | 18 | 0.18.0 19 | ------ 20 | 21 | - `SCIMAuthCheckMiddleware` is now applied as a decorator directly to 22 | the SCIM view functions. Therefore, it no longer needs to be 23 | configured as a middleware in Django's `MIDDLEWARE` setting. If you 24 | want to customize it, you can do so using the 25 | `AUTH_CHECK_MIDDLEWARE` key of the `SCIM_SERVICE_PROVIDER` setting. 26 | - Escape URL regexes so as not to match all characters in place of the 27 | period in `.search`. 28 | 29 | 0.17.3 30 | ------ 31 | 32 | - Update build-system from poetry to poetry-core 33 | - Update Django dependency from 3.2.12 to 3.2.13 34 | - Change scim2-filter-parser pin to lower bound 35 | 36 | 0.17.2 37 | ------ 38 | 39 | - Update tests to support Django4.0 40 | - Replace ugettext_lazy with gettext_lazy 41 | 42 | 0.17.1 43 | ------ 44 | 45 | - Update docs to include information for available settings. 46 | - Add a setting for exposing SCIM errors to clients, `EXPOSE_SCIM_EXCEPTIONS`. 47 | This setting defaults to false for security but can be toggled on for 48 | backwards compatibility. 49 | 50 | 0.17.0 51 | ------ 52 | 53 | - Inclusion of a post processor for querysets during GetView.get_many() calls. 54 | 55 | 0.16.6 56 | ------ 57 | 58 | - Re-release of package due to pyproject.toml bug. 59 | 60 | 0.16.5 61 | ------ 62 | 63 | - Re-release of package due to PyPI release bug. 64 | 65 | 0.16.4 66 | ------ 67 | 68 | - Bug fixes 69 | 70 | 0.16.3 71 | ------ 72 | - Consider nested fields in extra_filter_kwargs and extra_exclude_kwargs 73 | 74 | 0.16.2 75 | ------ 76 | - Drop explicit support for Python 3.6 77 | - Complete migration to GitHub Actions 78 | 79 | 0.16.2 80 | ------ 81 | - Upgrade version of scim2-filter-parser 82 | 83 | 0.16.1 84 | ------ 85 | - Fix distribution issues with pyproject.toml file 86 | 87 | 0.16.0 88 | ------ 89 | - Require SCIM IDs to be unique across all objects of a specific model. 90 | POTENTIALLY BREAKING CHANGE. 91 | - Drop compatibility with all Django versions before 2.2.13 92 | POTENTIALLY BREAKING CHANGE. 93 | 94 | 0.15.0 95 | ------ 96 | - Add a get_object_post_processor function. 97 | - Drop compatibility with all Django versions before 2.0 98 | POTENTIALLY BREAKING CHANGE. 99 | 100 | 0.14.4 101 | ------ 102 | - Fix hard coded ATTR_MAP issue (#38). Thank you @horida. 103 | - Update replace calls to call self.save() rather than self.obj.save() (#40) Thanks @horida. 104 | - Update demo documentation and requirements 105 | - Upgrade dependency: scim2-filter-parser -> 0.3.4 106 | - Move CI to CircleCI: https://circleci.com/gh/15five/django-scim2 107 | 108 | 0.14.3 109 | ------ 110 | - Handle SCIMParesrError errors gracefully 111 | 112 | 0.14.2 113 | ------ 114 | - Run PATCH handlers for each parsed path and value 115 | 116 | 0.14.1 117 | ------ 118 | - Fix issue #35. PATCH not working for boolean values 119 | 120 | 0.14.0 121 | ------ 122 | - Upgrade dependency: scim2-filter-parser -> 0.3.2 to support complex paths 123 | - Move to the use of AttrPath objects for determining if a PATCH path 124 | is complex or not. POTENTIALLY BREAKING CHANGE. 125 | 126 | 0.13.2 127 | ------ 128 | - Add ENTERPRISE_URN to list of schemas 129 | 130 | 0.13.1 131 | ------ 132 | - Allow for customization of paths to be logged 133 | 134 | 0.13.0 135 | ------ 136 | - Added validation methods to adapters 137 | 138 | 0.12.8 139 | ------ 140 | - Upgrade dependency: scim2-filter-parser -> 0.2.3 to fix tokenization error 141 | 142 | 0.12.7 143 | ------ 144 | - Refactor location of ATTR_MAP, placed in filters.py 145 | - Fixed NameError 146 | - Add testing around complex queries 147 | - Upgrade dependency: scim2-filter-parser -> 0.2.2 148 | 149 | 0.12.6 150 | ------ 151 | - Refactor ComplexAttrPaths to use custom class 152 | 153 | This changes the API for PATCH handlers. 154 | Handlers will take a 3-tuple as before and now, additionally, 155 | a ComplexAttrPath object that contains information regarding 156 | the complex path. Handlers look for this object to determine 157 | if they need to perform extra handling to determine the attribute 158 | that needs to be updated 159 | 160 | 0.12.5 161 | ------ 162 | - Upgrade dependency: scim2-filter-parser -> 0.2.1 163 | 164 | 0.12.4 165 | ------ 166 | - Upgrade dependency: scim2-filter-parser -> 0.2.0 167 | 168 | 0.12.3 169 | ------ 170 | - Retain capitalization in filter queries 171 | - Add tests 172 | 173 | 0.12.2 174 | ------ 175 | - Add AuthorizationError to exceptions 176 | 177 | 0.12.1 178 | ------ 179 | - Improve PATCH paths passed into handlers. 180 | 181 | If you have custom handlers for patch calls defined in your adapters file 182 | (eg. def handle_add(path, value, operation): ...) then you will need to 183 | update your handlers to accept a 3-tuple in place of the current string held 184 | by path. A quick fix for this is to add the following line to the beginning of 185 | each handler: 186 | 187 | path, subattr, uri = path 188 | 189 | Thanks for your patience as we migrate to a better system for handling PATCH 190 | paths. 191 | 192 | 0.12.0 193 | ------ 194 | - Include schemas in packaged version. Thanks @stefanfoulis! 195 | - Fix #21. Store and return externalId 196 | - Fix #22. Handle more complicated PATH calls 197 | Please note: the parse_path_and_value method on the SCIMMixin adapter 198 | changed signature. Please update your code accordingly. 199 | 200 | 0.11.1 201 | ------ 202 | - Add logic to add extra SQL to filters 203 | 204 | 0.11.0 205 | ------ 206 | - Replace PyPlus with scim2-filter-parser for better filter parsing 207 | - Move tests out of django_scim app folder to root of repo 208 | 209 | 0.10.7 210 | ------ 211 | - Return better errors for malformed or unsupported JSON bodies 212 | 213 | 0.10.6 214 | ------ 215 | - Create a NotImplementedError for SCIM 216 | - Refactor FilterTransformers 217 | 218 | 0.10.5 219 | ------ 220 | - Tone down the number of logged exceptions (if they are SCIMExceptions as those are somewhat expected) 221 | 222 | 0.10.4 223 | ------ 224 | - Fix logging on requests. Log on more than 401s. 225 | 226 | 0.10.3 227 | ------ 228 | - Increase logging level for failed SCIM view call 229 | 230 | 0.10.2 231 | ------ 232 | - Add separate logging methods for requests and response 233 | 234 | 0.10.1 235 | ------ 236 | - Add badges to README 237 | 238 | 0.10.0 239 | ------ 240 | - Drop support for Python2 241 | !!BREAKING CHANGE!! 242 | - Add linting and coverage 243 | 244 | 0.9.0 245 | ----- 246 | - Decouple Django object id and SCIM ID 247 | !!MASSIVE BREAKING CHANGE!! 248 | - Add tests for Abstract SCIM models 249 | 250 | 0.8.2 251 | ----- 252 | - Only log during SCIM calls 253 | 254 | 0.8.1 255 | ----- 256 | - Fix failing tests 257 | 258 | 0.8.0 259 | ----- 260 | - Add basic abstract models for Users and Groups 261 | - Update parse path function 262 | - Added more to demo application 263 | 264 | 0.7.0 265 | ----- 266 | - Change where logging of requests is done (now in middleware) 267 | - Improve demo application 268 | 269 | 0.6.0 270 | ----- 271 | - Add tests for work on supporting complex SCIM filtering 272 | - Update PATCH handler to decipher path key 273 | 274 | This commit contains a BREAKING CHANGE. 275 | 276 | If you have overridden the "handle_" methods on the User and 277 | Group adapters, those methods need to be updated to take path, and value 278 | in addition to operation. 279 | 280 | New adapter signature: 281 | 282 | def handle_(self, path, value, operation): 283 | ... 284 | 285 | 0.5.3 286 | ----- 287 | - Bug fix: https://github.com/15five/django-scim2/issues/13 288 | Thank you @tomatsue 289 | 290 | 0.5.2 291 | ----- 292 | - Bug fix 293 | 294 | 0.5.1 295 | ----- 296 | - Added tests for grammar 297 | - Added back explicit support for python2.7 on Django 1.11 298 | 299 | 0.5.0 300 | ----- 301 | - Added a group grammar for parsing group pushes 302 | 303 | 0.4.1 304 | ----- 305 | - Docs changes 306 | 307 | 0.4.0 308 | ----- 309 | ** BREAKING CHANGES ** 310 | - Port to Python 3.6, drop support for anything less than Python 3.6 311 | - Port to Django 1.11+, drop support for anything less than Django 1.11 312 | 313 | ** NON BREAKING CHANGES ** 314 | - change accuracy of timestamp from micro- to milli-second 315 | - Upgrade dateutil library 316 | - Support Django 2.0.0 317 | 318 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Original work Copyright (c) 2014 Erik van Zijst , Atlassian 4 | Modified work Copyright (c) 2016 Paul Logston , 15Five, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.rst 3 | include MANIFEST.in 4 | include tox.ini 5 | exclude requirements.txt 6 | recursive-include src *.py *.json *.rst 7 | recursive-exclude . *.pyc 8 | prune demo 9 | prune docs 10 | prune dist 11 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-scim2 2 | ============ 3 | 4 | |tests| |coverage| |docs| 5 | 6 | This is a provider-side implementation of the SCIM 2.0 [1]_ 7 | specification for use in Django. 8 | 9 | Note that currently the only supported database is Postgres. 10 | 11 | 12 | Installation 13 | ------------ 14 | 15 | Install with pip:: 16 | 17 | $ pip install django-scim2 18 | 19 | Then add the ``django_scim`` app to ``INSTALLED_APPS`` in your Django's settings:: 20 | 21 | INSTALLED_APPS = ( 22 | ... 23 | 'django_scim', 24 | ) 25 | 26 | By default, ``request.user.is_anonymous()`` is checked to determine if the SCIM 27 | request should be allowed or denied. 28 | 29 | If you have specific authentication needs, look into overriding the default "is 30 | authenticated predicate" (i.e. see ``GET_IS_AUTHENTICATED_PREDICATE`` for 31 | details) or subclassing the middleware that performs the check 32 | (``AUTH_CHECK_MIDDLEWARE``). 33 | 34 | Add the necessary url patterns to your root urls.py file. Please note that the 35 | namespace is mandatory and must be named `scim`:: 36 | 37 | urlpatterns = [ 38 | ... 39 | path('scim/v2/', include('django_scim.urls')), 40 | ] 41 | 42 | Finally, add settings appropriate for you app to your settings.py file:: 43 | 44 | SCIM_SERVICE_PROVIDER = { 45 | 'NETLOC': 'localhost', 46 | 'AUTHENTICATION_SCHEMES': [ 47 | { 48 | 'type': 'oauth2', 49 | 'name': 'OAuth 2', 50 | 'description': 'Oauth 2 implemented with bearer token', 51 | }, 52 | ], 53 | } 54 | 55 | Other SCIM settings can be provided but those listed above are required. 56 | 57 | PyPI 58 | ---- 59 | 60 | https://pypi.python.org/pypi/django-scim2 61 | 62 | Source 63 | ------ 64 | 65 | https://github.com/15five/django-scim2 66 | 67 | Documentation 68 | ------------- 69 | 70 | .. |docs| image:: https://readthedocs.org/projects/django-scim2/badge/ 71 | :target: https://django-scim2.readthedocs.io/ 72 | :alt: Documentation Status 73 | 74 | https://django-scim2.readthedocs.io/ 75 | 76 | Development 77 | ----------- 78 | 79 | This project uses Poetry to manage dependencies, etc. Thus to install the 80 | necessary tools when developing, run:: 81 | 82 | poetry install 83 | 84 | Tests 85 | ----- 86 | 87 | .. |tests| image:: https://github.com/15five/django-scim2/workflows/CI%2FCD/badge.svg 88 | :target: https://github.com/15five/django-scim2/actions 89 | 90 | https://github.com/15five/django-scim2/actions 91 | 92 | Tests are typically run locally with `tox` (https://tox.wiki/). Tox will test 93 | all supported versions of Python and Django:: 94 | 95 | tox 96 | 97 | To run the test suite with a single version of Python (the version you created 98 | the virtualenv with), run:: 99 | 100 | poetry run pytest tests/ 101 | 102 | Coverage 103 | -------- 104 | 105 | .. |coverage| image:: https://codecov.io/gh/15five/django-scim2/graph/badge.svg 106 | :target: https://codecov.io/gh/15five/django-scim2 107 | 108 | https://codecov.io/gh/15five/django-scim2/ 109 | 110 | To run tests with coverage:: 111 | 112 | tox -e coverage 113 | 114 | 115 | License 116 | ------- 117 | 118 | This library is released under the terms of the **MIT license**. Full details in ``LICENSE.txt`` file. 119 | 120 | 121 | Extensibility 122 | ------------- 123 | 124 | This library was forked and developed to be highly extensible. A number of 125 | adapters can be defined to control what different endpoints do to your resources. 126 | Please see the documentation for more details. 127 | 128 | PLEASE NOTE: This app does not implement authorization and authentication. 129 | Such tasks are left for other apps such as `Django OAuth Toolkit`_ to implement. 130 | 131 | .. _`Django OAuth Toolkit`: https://github.com/evonove/django-oauth-toolkit 132 | 133 | Development Speed 134 | ----------------- 135 | 136 | Since this project is relatively stable, time is only dedicated to it on 137 | Fridays. Thus if you issue a PR, bug, etc, please note that it may take a week 138 | before we get back to you. Thanks you for your patience. 139 | 140 | Credits 141 | ------- 142 | 143 | This project was forked from https://bitbucket.org/atlassian/django_scim 144 | 145 | 146 | .. [1] http://www.simplecloud.info/, https://tools.ietf.org/html/rfc7644 147 | -------------------------------------------------------------------------------- /demo/README: -------------------------------------------------------------------------------- 1 | This app is intended to be an example of how one can override 2 | some of the defaults in django-scim2 to fit your needs. 3 | 4 | This app is not meant to be a kitchen sink, showing all options that can 5 | be used or overridden. This is a jumping off point. 6 | 7 | This app is also not guaranteed to be functional at all times. 8 | This demo app is only supposed to be a loose example of what is possible. 9 | 10 | -------------------------------------------------------------------------------- /demo/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/15five/django-scim2/078e85f034b9e083d4a98437277ac2f9fd49ae1d/demo/app/__init__.py -------------------------------------------------------------------------------- /demo/app/adapters.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | 4 | from django import core 5 | from django.db import transaction 6 | 7 | from app import constants 8 | from app.models import Group 9 | from django_scim import exceptions as scim_exceptions 10 | from django_scim.adapters import SCIMUser 11 | from django_scim.utils import get_group_adapter 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class SCIMUser(SCIMUser): 18 | 19 | password_changed = False 20 | activity_changed = False 21 | 22 | def __init__(self, obj, request=None): 23 | super().__init__(obj, request) 24 | 25 | self._from_dict_copy = None 26 | 27 | @property 28 | def is_new_user(self): 29 | return not bool(self.obj.id) 30 | 31 | @property 32 | def display_name(self): 33 | """ 34 | Return the displayName of the user per the SCIM spec. 35 | """ 36 | if self.obj.first_name and self.obj.last_name: 37 | return f'{self.obj.first_name} {self.obj.last_name}' 38 | return self.obj.email 39 | 40 | @property 41 | def groups(self): 42 | """ 43 | Return the groups of the user per the SCIM spec. 44 | """ 45 | 46 | group_qs = Group.objects.filter(members__member=self.obj) 47 | scim_groups = [get_group_adapter()(g, self.request) for g in group_qs] 48 | 49 | dicts = [] 50 | for group in scim_groups: 51 | d = { 52 | 'value': group.scim_id, 53 | '$ref': group.location, 54 | 'display': group.display_name, 55 | } 56 | dicts.append(d) 57 | 58 | return dicts 59 | 60 | @property 61 | def meta(self): 62 | """ 63 | Return the meta object of the user per the SCIM spec. 64 | """ 65 | d = { 66 | 'resourceType': self.resource_type, 67 | 'created': self.obj.create_ts.isoformat(timespec='milliseconds'), 68 | 'lastModified': self.obj.update_ts.isoformat(timespec='milliseconds'), 69 | 'location': self.location, 70 | } 71 | 72 | return d 73 | 74 | def to_dict(self): 75 | """ 76 | Return a ``dict`` conforming to the SCIM User Schema, 77 | ready for conversion to a JSON object. 78 | """ 79 | d = super().to_dict() 80 | d.update({ 81 | 'userName': self.obj.scim_username, 82 | constants.SCHEMA_URI_APP_USER: { 83 | 'companyId': self.obj.company_id, 84 | }, 85 | }) 86 | 87 | return d 88 | 89 | def from_dict(self, d): 90 | """ 91 | Consume a ``dict`` conforming to the SCIM User Schema, updating the 92 | internal user object with data from the ``dict``. 93 | 94 | Please note, the user object is not saved within this method. To 95 | persist the changes made by this method, please call ``.save()`` on the 96 | adapter. Eg:: 97 | 98 | scim_user.from_dict(d) 99 | scim_user.save() 100 | """ 101 | # Store dict for possible later use when saving user 102 | self._from_dict_copy = copy.deepcopy(d) 103 | 104 | self.obj.company_id = self.request.user.company_id 105 | 106 | self.parse_active(d.get('active')) 107 | 108 | self.obj.first_name = d.get('name', {}).get('givenName') or '' 109 | 110 | self.obj.last_name = d.get('name', {}).get('familyName') or '' 111 | 112 | self.parse_email(d.get('emails')) 113 | 114 | if self.is_new_user and not self.obj.email: 115 | raise scim_exceptions.BadRequestError('Empty email value') 116 | 117 | self.obj.scim_username = d.get('userName') 118 | self.obj.scim_external_id = d.get('externalId') or '' 119 | 120 | cleartext_password = d.get('password') 121 | if cleartext_password: 122 | self.obj.set_password(cleartext_password) 123 | self.obj._scim_cleartext_password = cleartext_password 124 | self.password_changed = True 125 | 126 | def parse_active(self, active): 127 | if active is not None: 128 | if active != self.obj.is_active: 129 | self.activity_changed = True 130 | self.obj.is_active = active 131 | 132 | def parse_email(self, emails_value): 133 | if emails_value: 134 | email = None 135 | if isinstance(emails_value, list): 136 | primary_emails = [e['value'] for e in emails_value if e.get('primary')] 137 | other_emails = [e['value'] for e in emails_value if not e.get('primary')] 138 | # Make primary emails the first in the list 139 | sorted_emails = list(map(str.strip, primary_emails + other_emails)) 140 | email = sorted_emails[0] if sorted_emails else None 141 | elif isinstance(emails_value, dict): 142 | # if value is a dict, let's assume it contains the primary email. 143 | # OneLogin sends a dict despite the spec: 144 | # https://tools.ietf.org/html/rfc7643#section-4.1.2 145 | # https://tools.ietf.org/html/rfc7643#section-8.2 146 | email = (emails_value.get('value') or '').strip() 147 | 148 | self.validate_email(email) 149 | 150 | self.obj.email = email 151 | 152 | @staticmethod 153 | def validate_email(email): 154 | try: 155 | validator = core.validators.EmailValidator() 156 | validator(email) 157 | except core.exceptions.ValidationError: 158 | raise scim_exceptions.BadRequestError('Invalid email value') 159 | 160 | def save(self): 161 | temp_password = None 162 | if self.is_new_user: 163 | password = getattr(self.obj, '_scim_cleartext_password', None) 164 | # If temp password was not passed, create one. 165 | if password is None: 166 | self.obj.require_password_change = True 167 | temp_password = generate_temp_password() 168 | password = temp_password 169 | self.obj.set_password(password) 170 | 171 | is_new_user = self.is_new_user 172 | try: 173 | with transaction.atomic(): 174 | super().save() 175 | if is_new_user: 176 | # Set SCIM ID to be equal to database ID. Because users are uniquely identified with this value 177 | # its critical that changes to this line are well considered before executed. 178 | self.obj.__class__.objects.update(scim_id=str(self.obj.id)) 179 | logger.info(f'User saved. User id {self.obj.id}') 180 | except Exception as e: 181 | raise self.reformat_exception(e) 182 | 183 | def delete(self): 184 | self.obj.is_active = False 185 | self.obj.save() 186 | logger.info(f'Deactivated user id {self.obj.id}') 187 | 188 | def handle_add(self, path, value, operation): 189 | if path == 'externalId': 190 | self.obj.scim_external_id = value 191 | self.obj.save() 192 | 193 | def handle_replace(self, path, value, operation): 194 | """ 195 | Handle the replace operations. 196 | 197 | All operations happen within an atomic transaction. 198 | """ 199 | attr_map = { 200 | 'familyName': 'last_name', 201 | 'givenName': 'first_name', 202 | 'active': 'is_active', 203 | 'userName': 'scim_username', 204 | 'externalId': 'scim_external_id', 205 | } 206 | 207 | for attr, attr_value in (value or {}).items(): 208 | if attr in attr_map: 209 | setattr(self.obj, attr_map.get(attr), attr_value) 210 | 211 | elif attr == 'emails': 212 | self.parse_email(attr_value) 213 | 214 | elif attr == 'password': 215 | self.obj.set_password(attr_value) 216 | 217 | else: 218 | raise scim_exceptions.SCIMException('Not Implemented', status=409) 219 | 220 | self.obj.save() 221 | 222 | @classmethod 223 | def resource_type_dict(cls, request=None): 224 | d = super().resource_type_dict(request) 225 | d['schemaExtensions'] = [ 226 | { 227 | 'schema': constants.SCHEMA_URI_APP_USER, 228 | 'required': False, 229 | }, 230 | ] 231 | return d 232 | -------------------------------------------------------------------------------- /demo/app/constants.py: -------------------------------------------------------------------------------- 1 | 2 | SCHEMA_URI_APP_USER = 'urn:demo-app:params:scim:schemas:extension:demo-app:2.0:User' 3 | -------------------------------------------------------------------------------- /demo/app/log_handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fluent.handler import FluentHandler 4 | 5 | 6 | class SCIMFluentHandler(FluentHandler): 7 | """ 8 | And example of a custom log handler that ships data recovered 9 | from CustomSCIMAuthCheckMiddleware to Fluentd. 10 | """ 11 | def emit(self, record): 12 | self.parse_message(record) 13 | self.obfuscate_record(record) 14 | data = self.format(record) 15 | return self.sender.emit(None, data) 16 | 17 | def parse_message(self, record): 18 | """ 19 | Parse message on record for data set in overridden middleware. 20 | See CustomSCIMAuthCheckMiddleware.get_loggable_request_message. 21 | """ 22 | # If record is a dict, lets assume we should set all items on 23 | # that dict as attributes on the record. The logging logic in Python 24 | # uses .__dict__ on the record to populate the outgoing message in 25 | # logging.Handler.format(). 26 | if isinstance(record.msg, dict): 27 | for key, value in record.msg.items(): 28 | setattr(record, key, value) 29 | 30 | # Reset the message to be the body of the SCIM call. 31 | record.msg = record.msg['body'] 32 | 33 | def obfuscate_record(self, record): 34 | """ 35 | Strip sensitive info from record. 36 | """ 37 | try: 38 | obj = json.loads(record.msg) 39 | except: 40 | return 41 | 42 | obj = recursive_obfuscate(obj) 43 | 44 | record.msg = json.dumps(obj) 45 | 46 | 47 | def recursive_obfuscate(obj, keys_to_obfuscate=('password',)): 48 | """ 49 | Recursively obfuscate the values of keys with names specified in ``keys_to_obfuscate``. 50 | 51 | return: The same object passed in but hopefully obfuscated. 52 | """ 53 | if isinstance(obj, dict): 54 | keys = obj.keys() 55 | for key in keys: 56 | if key.lower() in keys_to_obfuscate: 57 | obj[key] = '*' * 10 58 | else: 59 | obj[key] = recursive_obfuscate(obj[key], keys_to_obfuscate) 60 | 61 | return obj 62 | -------------------------------------------------------------------------------- /demo/app/middleware.py: -------------------------------------------------------------------------------- 1 | from django_scim.middleware import SCIMAuthCheckMiddleware 2 | 3 | 4 | class CustomSCIMAuthCheckMiddleware(SCIMAuthCheckMiddleware): 5 | """ 6 | An example of overriding the SCIM middleware to log more data 7 | around each request. 8 | """ 9 | 10 | def get_loggable_response_message(self, request, response): 11 | body = self.get_loggable_content(response.content) 12 | 13 | return { 14 | 'request_absolute_uri': request.build_absolute_uri(), 15 | 'request_method': request.method, 16 | 'body': body, 17 | 'response_status_code': response.status_code, 18 | } 19 | -------------------------------------------------------------------------------- /demo/app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.20 on 2019-02-26 21:24 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | import django_extensions.db.fields 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='User', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('password', models.CharField(max_length=128, verbose_name='password')), 25 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 26 | ('scim_id', models.CharField(blank=True, db_index=True, default=None, help_text='A unique identifier for a SCIM resource as defined by the service provider.', max_length=254, null=True, verbose_name='SCIM ID')), 27 | ('scim_external_id', models.CharField(blank=True, db_index=True, default=None, help_text='A string that is an identifier for the resource as defined by the provisioning client.', max_length=254, null=True, verbose_name='SCIM External ID')), 28 | ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), 29 | ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), 30 | ('scim_username', models.CharField(blank=True, default=None, help_text="A service provider's unique identifier for the user", max_length=254, null=True, unique=True, verbose_name='SCIM Username')), 31 | ('email', models.EmailField(max_length=254, verbose_name='Email')), 32 | ('first_name', models.CharField(max_length=100, verbose_name='First Name')), 33 | ('last_name', models.CharField(max_length=100, verbose_name='Last Name')), 34 | ], 35 | options={ 36 | 'abstract': False, 37 | }, 38 | ), 39 | migrations.CreateModel( 40 | name='Company', 41 | fields=[ 42 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 43 | ('name', models.CharField(max_length=100, verbose_name='Name')), 44 | ], 45 | ), 46 | migrations.CreateModel( 47 | name='Group', 48 | fields=[ 49 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 50 | ('scim_id', models.CharField(blank=True, db_index=True, default=None, help_text='A unique identifier for a SCIM resource as defined by the service provider.', max_length=254, null=True, verbose_name='SCIM ID')), 51 | ('scim_external_id', models.CharField(blank=True, db_index=True, default=None, help_text='A string that is an identifier for the resource as defined by the provisioning client.', max_length=254, null=True, verbose_name='SCIM External ID')), 52 | ('scim_display_name', models.CharField(blank=True, db_index=True, default=None, help_text='A human-readable name for the Group.', max_length=254, null=True, verbose_name='SCIM Display Name')), 53 | ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), 54 | ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), 55 | ('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.Company')), 56 | ], 57 | options={ 58 | 'ordering': ('-modified', '-created'), 59 | 'get_latest_by': 'modified', 60 | 'abstract': False, 61 | }, 62 | ), 63 | migrations.CreateModel( 64 | name='GroupMembership', 65 | fields=[ 66 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 67 | ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.Group')), 68 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 69 | ], 70 | ), 71 | migrations.AddField( 72 | model_name='group', 73 | name='members', 74 | field=models.ManyToManyField(through='app.GroupMembership', to=settings.AUTH_USER_MODEL), 75 | ), 76 | migrations.AddField( 77 | model_name='user', 78 | name='company', 79 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.Company'), 80 | ), 81 | ] 82 | -------------------------------------------------------------------------------- /demo/app/migrations/0002_alter_group_options_alter_user_managers_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.18 on 2025-03-06 04:20 2 | 3 | import app.models 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('app', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='group', 17 | options={'get_latest_by': 'modified'}, 18 | ), 19 | migrations.AlterModelManagers( 20 | name='user', 21 | managers=[ 22 | ('objects', app.models.ScimUserManager()), 23 | ], 24 | ), 25 | migrations.AddField( 26 | model_name='user', 27 | name='is_staff', 28 | field=models.BooleanField(default=False), 29 | ), 30 | migrations.AddField( 31 | model_name='user', 32 | name='is_superuser', 33 | field=models.BooleanField(default=False), 34 | ), 35 | migrations.AlterField( 36 | model_name='group', 37 | name='scim_id', 38 | field=models.CharField( 39 | blank=True, 40 | default=None, 41 | help_text='A unique identifier for a SCIM resource as defined by the service provider.', 42 | max_length=254, 43 | null=True, 44 | unique=True, 45 | verbose_name='SCIM ID', 46 | ), 47 | ), 48 | migrations.AlterField( 49 | model_name='user', 50 | name='company', 51 | field=models.ForeignKey( 52 | blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.company' 53 | ), 54 | ), 55 | migrations.AlterField( 56 | model_name='user', 57 | name='scim_id', 58 | field=models.CharField( 59 | blank=True, 60 | default=None, 61 | help_text='A unique identifier for a SCIM resource as defined by the service provider.', 62 | max_length=254, 63 | null=True, 64 | unique=True, 65 | verbose_name='SCIM ID', 66 | ), 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /demo/app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/15five/django-scim2/078e85f034b9e083d4a98437277ac2f9fd49ae1d/demo/app/migrations/__init__.py -------------------------------------------------------------------------------- /demo/app/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin 3 | from django.db import models 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from django_extensions.db.models import TimeStampedModel 7 | from django_scim.models import AbstractSCIMGroupMixin, AbstractSCIMUserMixin 8 | 9 | 10 | class Company(models.Model): 11 | name = models.CharField( 12 | _('Name'), 13 | max_length=100, 14 | ) 15 | 16 | 17 | class ScimUserManager(BaseUserManager): 18 | use_in_migrations = True 19 | 20 | def _create_user(self, scim_username, password, **extra_fields): 21 | if not scim_username: 22 | raise ValueError('Users require a scim_usernamefield') 23 | user = self.model(scim_username=scim_username, **extra_fields) 24 | user.set_password(password) 25 | user.save(using=self._db) 26 | return user 27 | 28 | def create_user(self, scim_username, password=None, **extra_fields): 29 | company, _ = Company.objects.get_or_create(name="Demo Inc") 30 | extra_fields.setdefault('is_staff', False) 31 | extra_fields.setdefault('is_superuser', False) 32 | extra_fields.setdefault('company_id', company.id) 33 | return self._create_user(scim_username, password, **extra_fields) 34 | 35 | def create_superuser(self, scim_username, password, **extra_fields): 36 | extra_fields.setdefault('is_staff', True) 37 | extra_fields.setdefault('is_superuser', True) 38 | return self._create_user(scim_username, password, **extra_fields) 39 | 40 | 41 | class User(AbstractSCIMUserMixin, TimeStampedModel, AbstractBaseUser, PermissionsMixin): 42 | company = models.ForeignKey( 43 | 'app.Company', 44 | blank=True, 45 | null=True, 46 | on_delete=models.CASCADE, 47 | ) 48 | 49 | is_staff = models.BooleanField(default=False) 50 | is_superuser = models.BooleanField(default=False) 51 | 52 | # Why override this? Can't we just use what the AbstractSCIMUser mixin 53 | # gives us? The USERNAME_FIELD needs to be "unique" and for flexibility, 54 | # AbstractSCIMUser.scim_username is not unique by default. 55 | scim_username = models.CharField( 56 | _('SCIM Username'), 57 | max_length=254, 58 | null=True, 59 | blank=True, 60 | default=None, 61 | unique=True, 62 | help_text=_("A service provider's unique identifier for the user"), 63 | ) 64 | 65 | email = models.EmailField( 66 | _('Email'), 67 | ) 68 | 69 | first_name = models.CharField( 70 | _('First Name'), 71 | max_length=100, 72 | ) 73 | 74 | last_name = models.CharField( 75 | _('Last Name'), 76 | max_length=100, 77 | ) 78 | 79 | USERNAME_FIELD = 'scim_username' 80 | 81 | def get_full_name(self): 82 | return self.first_name + ' ' + self.last_name 83 | 84 | def get_short_name(self): 85 | return self.first_name + (' ' + self.last_name[0] if self.last_name else '') 86 | 87 | objects = ScimUserManager() 88 | 89 | 90 | class Group(TimeStampedModel, AbstractSCIMGroupMixin): 91 | company = models.ForeignKey( 92 | 'app.Company', 93 | on_delete=models.CASCADE, 94 | ) 95 | 96 | members = models.ManyToManyField( 97 | settings.AUTH_USER_MODEL, 98 | through='GroupMembership', 99 | through_fields=('group', 'user'), 100 | ) 101 | 102 | @property 103 | def name(self): 104 | return self.scim_display_name 105 | 106 | 107 | class GroupMembership(models.Model): 108 | user = models.ForeignKey( 109 | to=settings.AUTH_USER_MODEL, 110 | on_delete=models.CASCADE, 111 | ) 112 | 113 | group = models.ForeignKey(to='app.Group', on_delete=models.CASCADE) 114 | -------------------------------------------------------------------------------- /demo/app/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def get_full_domain_from_request(request): 5 | domain = settings.MAIN_DOMAIN 6 | 7 | host = request.META.get('HTTP_X_FORWARDED_HOST', '') # for proxy forwards 8 | if not host: 9 | host = request.META.get('HTTP_HOST', '') 10 | host_parts = host.split('.') 11 | 12 | subdomain = '' 13 | if len(host_parts) > 2: 14 | # all parts sans the domain, assumes main domain is a two part domain 15 | subdomain = '.'.join(host_parts[:-2]) 16 | 17 | if subdomain: 18 | domain = subdomain + '.' + domain 19 | 20 | return '{scheme}{domain}'.format( 21 | scheme='https://' if request.is_secure() else 'http://', 22 | domain=domain, 23 | ) 24 | 25 | 26 | def get_extra_model_filter_kwargs_getter(model): 27 | """ 28 | Return a function that will return extra model filter kwargs for the passed in model. 29 | 30 | :param model: 31 | """ 32 | 33 | if getattr(model, '__name__', None) == 'User': 34 | 35 | def get_extra_filter_kwargs(self, request, *args, **kwargs): 36 | """ 37 | Return extra filter kwargs for the given model. 38 | :param request: 39 | :param args: 40 | :param kwargs: 41 | :rtype: dict 42 | """ 43 | return { 44 | 'company_id': request.user.company_id, 45 | } 46 | 47 | elif getattr(model, '__name__', None) == 'Group': 48 | 49 | def get_extra_filter_kwargs(self, request, *args, **kwargs): 50 | """ 51 | Return extra filter kwargs for the given model. 52 | 53 | :param request: 54 | :param args: 55 | :param kwargs: 56 | :rtype: dict 57 | """ 58 | return { 59 | 'company_id': request.user.company_id, 60 | } 61 | 62 | else: 63 | 64 | # For 'search' case 65 | def get_extra_filter_kwargs(self, request, *args, **kwargs): 66 | """ 67 | Return extra filter kwargs for the given model. 68 | 69 | :param request: 70 | :param args: 71 | :param kwargs: 72 | :rtype: dict 73 | """ 74 | return { 75 | 'company_id': request.user.company_id, 76 | } 77 | 78 | return get_extra_filter_kwargs 79 | 80 | 81 | def get_extra_model_exclude_kwargs_getter(model): 82 | """ 83 | Return a function that will return extra model exclude kwargs for the passed in model. 84 | 85 | :param model: 86 | """ 87 | 88 | if getattr(model, '__name__', None) == 'User': 89 | 90 | def get_extra_exclude_kwargs(self, request, *args, **kwargs): 91 | """ 92 | Return extra exclude kwargs for the given model. 93 | :param request: 94 | :param args: 95 | :param kwargs: 96 | :rtype: dict 97 | """ 98 | return {} 99 | 100 | elif getattr(model, '__name__', None) == 'Group': 101 | 102 | def get_extra_exclude_kwargs(self, request, *args, **kwargs): 103 | """ 104 | Return extra exclude kwargs for the given model. 105 | 106 | :param request: 107 | :param args: 108 | :param kwargs: 109 | :rtype: dict 110 | """ 111 | return {} 112 | 113 | else: 114 | 115 | # For 'search' case 116 | def get_extra_exclude_kwargs(self, request, *args, **kwargs): 117 | """ 118 | Return extra exclude kwargs for the given model. 119 | 120 | :param request: 121 | :param args: 122 | :param kwargs: 123 | :rtype: dict 124 | """ 125 | return {} 126 | 127 | return get_extra_exclude_kwargs 128 | -------------------------------------------------------------------------------- /demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "root.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /demo/requirements.txt: -------------------------------------------------------------------------------- 1 | django>=3.2,<5.2 2 | django-extensions==4.1 3 | django-oauth-toolkit==3.0.1 4 | django-scim2 # not pinning, should always work with latest release 5 | fluent-logger==0.11.1 6 | -------------------------------------------------------------------------------- /demo/root/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/15five/django-scim2/078e85f034b9e083d4a98437277ac2f9fd49ae1d/demo/root/__init__.py -------------------------------------------------------------------------------- /demo/root/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for demo project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.8.16. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.8/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | from fluent import sender 17 | 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = '5#5-q4tff9mkg)9j)_hdyu8y4le(4_58luq7)0g0_lu_jv*xv@' 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = ['*'] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = ( 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'django_scim', 42 | 'oauth2_provider', 43 | 'app', 44 | ) 45 | 46 | MIDDLEWARE = ( 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | 'django.middleware.security.SecurityMiddleware', 55 | 'oauth2_provider.middleware.OAuth2TokenMiddleware', 56 | ) 57 | 58 | AUTHENTICATION_BACKENDS = [ 59 | # Django default backend 60 | 'django.contrib.auth.backends.ModelBackend', 61 | # used for SCIM integration 62 | 'oauth2_provider.backends.OAuth2Backend', 63 | ] 64 | 65 | AUTH_USER_MODEL = 'app.User' 66 | 67 | ROOT_URLCONF = 'root.urls' 68 | 69 | TEMPLATES = [ 70 | { 71 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 72 | 'DIRS': [], 73 | 'APP_DIRS': True, 74 | 'OPTIONS': { 75 | 'context_processors': [ 76 | 'django.template.context_processors.debug', 77 | 'django.template.context_processors.request', 78 | 'django.contrib.auth.context_processors.auth', 79 | 'django.contrib.messages.context_processors.messages', 80 | ], 81 | }, 82 | }, 83 | ] 84 | 85 | WSGI_APPLICATION = 'root.wsgi.application' 86 | 87 | # Database 88 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 89 | 90 | DATABASES = { 91 | 'default': { 92 | 'ENGINE': 'django.db.backends.sqlite3', 93 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 94 | } 95 | } 96 | 97 | # Internationalization 98 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 99 | 100 | LANGUAGE_CODE = 'en-us' 101 | 102 | TIME_ZONE = 'UTC' 103 | 104 | USE_I18N = True 105 | 106 | USE_TZ = True 107 | 108 | 109 | # Logging 110 | # Start Fluentd logging system 111 | sender.setup('app') 112 | 113 | LOGGING = { 114 | 'version': 1, 115 | 'disable_existing_loggers': False, 116 | 'filters': { 117 | 'require_debug_false': { 118 | '()': 'django.utils.log.CallbackFilter', 119 | 'callback': lambda r: not DEBUG, 120 | } 121 | }, 122 | 'formatters': { 123 | 'verbose': { 124 | 'format': '%(levelname)s %(asctime)s %(name)s %(module)s %(process)d %(thread)d %(message)s' 125 | }, 126 | 'medium': {'format': '%(levelname)s %(asctime)s %(message)s'}, 127 | 'simple': {'format': '%(levelname)s %(message)s'}, 128 | 'fluentd': { 129 | '()': 'fluent.handler.FluentRecordFormatter', 130 | 'format': { 131 | 'hostname': '%(hostname)s', 132 | 'asctime': '%(asctime)s', 133 | 'created': '%(created)f', 134 | 'filename': '%(filename)s', 135 | 'funcName': '%(funcName)s', 136 | 'levelname': '%(levelname)s', 137 | 'levelno': '%(levelno)s', 138 | 'lineno': '%(lineno)d', 139 | 'module': '%(module)s', 140 | 'msecs': '%(msecs)d', 141 | 'message': '%(message)s', 142 | 'name': '%(name)s', 143 | 'pathname': '%(pathname)s', 144 | 'process': '%(process)d', 145 | 'processName': '%(processName)s', 146 | 'relativeCreated': '%(relativeCreated)d', 147 | 'thread': '%(thread)d', 148 | 'threadName': '%(threadName)s', 149 | 'exc_text': '%(exc_text)s', 150 | }, 151 | }, 152 | # This formatter is specifically for use with the 153 | # SCIMFluentHandler handler. 154 | 'scim-details': { 155 | '()': 'fluent.handler.FluentRecordFormatter', 156 | 'format': { 157 | 'hostname': '%(hostname)s', 158 | 'asctime': '%(asctime)s', 159 | 'created': '%(created)f', 160 | 'filename': '%(filename)s', 161 | 'funcName': '%(funcName)s', 162 | 'levelname': '%(levelname)s', 163 | 'levelno': '%(levelno)s', 164 | 'lineno': '%(lineno)d', 165 | 'module': '%(module)s', 166 | 'msecs': '%(msecs)d', 167 | 'message': '%(message)s', 168 | 'name': '%(name)s', 169 | 'pathname': '%(pathname)s', 170 | 'process': '%(process)d', 171 | 'processName': '%(processName)s', 172 | 'relativeCreated': '%(relativeCreated)d', 173 | 'thread': '%(thread)d', 174 | 'threadName': '%(threadName)s', 175 | 'exc_text': '%(exc_text)s', 176 | # SCIM specifics 177 | 'request_absolute_uri': '%(request_absolute_uri)s', 178 | 'request_method': '%(request_method)s', 179 | 'response_status_code': '%(response_status_code)s', 180 | }, 181 | }, 182 | }, 183 | 'handlers': { 184 | 'mail_admins': { 185 | 'level': 'ERROR', 186 | 'filters': ['require_debug_false'], 187 | 'class': 'django.utils.log.AdminEmailHandler', 188 | }, 189 | 'debug': { 190 | 'level': 'DEBUG', 191 | 'class': 'fluent.handler.FluentHandler', 192 | 'formatter': 'fluentd', 193 | 'tag': 'app.debug', 194 | }, 195 | 'scim': { 196 | 'level': 'DEBUG', 197 | 'class': 'app.log_handlers.SCIMFluentHandler', 198 | 'formatter': 'scim-details', 199 | 'tag': 'app.scim', 200 | }, 201 | }, 202 | } 203 | 204 | # Static files (CSS, JavaScript, Images) 205 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 206 | 207 | STATIC_URL = '/static/' 208 | 209 | SCIM_SERVICE_PROVIDER = { 210 | 'USER_ADAPTER': 'app.adapters.SCIMUser', 211 | 'GROUP_MODEL': 'app.models.Group', 212 | 'GROUP_ADAPTER': 'django_scim.adapters.SCIMGroup', 213 | 'SERVICE_PROVIDER_CONFIG_MODEL': 'django_scim.models.SCIMServiceProviderConfig', 214 | 'BASE_LOCATION_GETTER': 'app.utils.get_full_domain_from_request', 215 | 'GET_EXTRA_MODEL_FILTER_KWARGS_GETTER': 'app.utils.get_extra_model_filter_kwargs_getter', 216 | 'GET_EXTRA_MODEL_EXCLUDE_KWARGS_GETTER': 'app.utils.get_extra_model_exclude_kwargs_getter', 217 | 'SCHEMAS_GETTER': 'django_scim.schemas.default_schemas_getter', 218 | 'DOCUMENTATION_URI': None, 219 | 'SCHEME': 'https', 220 | # use default value, this will be overridden by value returned by BASE_LOCATION_GETTER 221 | 'NETLOC': 'localhost', 222 | 'AUTH_CHECK_MIDDLEWARE': 'app.middleware.CustomSCIMAuthCheckMiddleware', 223 | 'AUTHENTICATION_SCHEMES': [ 224 | { 225 | 'type': 'oauth2', 226 | 'name': 'OAuth 2', 227 | 'description': 'Oauth 2 implemented with bearer token', 228 | 'specUri': '', 229 | 'documentationUri': '', 230 | }, 231 | ], 232 | 'WWW_AUTHENTICATE_HEADER': 'Basic realm="Template App SCIM2.0"', 233 | } 234 | -------------------------------------------------------------------------------- /demo/root/urls.py: -------------------------------------------------------------------------------- 1 | """demo URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.8/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 14 | """ 15 | 16 | from django.urls import include, path 17 | from django.contrib import admin 18 | 19 | from django_scim.urls import urlpatterns as django_scim_urls 20 | 21 | urlpatterns = [ 22 | path('scim/v2/', include((django_scim_urls, "scim"), namespace="scim")), 23 | path('admin/', admin.site.urls), 24 | ] 25 | -------------------------------------------------------------------------------- /demo/root/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for Demo project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = django-scim2 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/adapters.rst: -------------------------------------------------------------------------------- 1 | Adapters 2 | ======== 3 | 4 | .. automodule:: django_scim.adapters 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-scim2 documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Dec 14 17:41:32 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 25 | import django;django.setup() 26 | 27 | import django_scim # isort:skip 28 | 29 | 30 | # -- General configuration ------------------------------------------------ 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | # 34 | # needs_sphinx = '1.0' 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be 37 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 38 | # ones. 39 | extensions = [ 40 | 'sphinx.ext.autodoc', 41 | 'sphinx.ext.intersphinx', 42 | 'sphinx.ext.todo', 43 | 'sphinx.ext.coverage', 44 | 'sphinx.ext.viewcode' 45 | ] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ['_templates'] 49 | 50 | # The suffix(es) of source filenames. 51 | # You can specify multiple suffix as a list of string: 52 | # 53 | # source_suffix = ['.rst', '.md'] 54 | source_suffix = '.rst' 55 | 56 | # The master toctree document. 57 | master_doc = 'index' 58 | 59 | # General information about the project. 60 | project = u'django-scim2' 61 | copyright = u'2016, Paul Logston' 62 | author = u'Paul Logston' 63 | 64 | # The version info for the project you're documenting, acts as replacement for 65 | # |version| and |release|, also used in various other places throughout the 66 | # built documents. 67 | # 68 | # The short X.Y version. 69 | # version = django_scim.__version__ 70 | # The full version, including alpha/beta/rc tags. 71 | # release = django_scim.__version__ 72 | 73 | # The language for content autogenerated by Sphinx. Refer to documentation 74 | # for a list of supported languages. 75 | # 76 | # This is also used if you do content translation via gettext catalogs. 77 | # Usually you set "language" from the command line for these cases. 78 | language = None 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | # This patterns also effect to html_static_path and html_extra_path 83 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # If true, `todo` and `todoList` produce output, else they produce nothing. 89 | todo_include_todos = True 90 | 91 | 92 | # -- Options for HTML output ---------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | # 97 | html_theme = 'alabaster' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | # 103 | # html_theme_options = {} 104 | 105 | # Add any paths that contain custom static files (such as style sheets) here, 106 | # relative to this directory. They are copied after the builtin static files, 107 | # so a file named "default.css" will overwrite the builtin "default.css". 108 | html_static_path = ['_static'] 109 | 110 | 111 | # -- Options for HTMLHelp output ------------------------------------------ 112 | 113 | # Output file base name for HTML help builder. 114 | htmlhelp_basename = 'django-scim2doc' 115 | 116 | 117 | # -- Options for LaTeX output --------------------------------------------- 118 | 119 | latex_elements = { 120 | # The paper size ('letterpaper' or 'a4paper'). 121 | # 122 | # 'papersize': 'letterpaper', 123 | 124 | # The font size ('10pt', '11pt' or '12pt'). 125 | # 126 | # 'pointsize': '10pt', 127 | 128 | # Additional stuff for the LaTeX preamble. 129 | # 130 | # 'preamble': '', 131 | 132 | # Latex figure (float) alignment 133 | # 134 | # 'figure_align': 'htbp', 135 | } 136 | 137 | # Grouping the document tree into LaTeX files. List of tuples 138 | # (source start file, target name, title, 139 | # author, documentclass [howto, manual, or own class]). 140 | latex_documents = [ 141 | (master_doc, 'django-scim2.tex', u'django-scim2 Documentation', 142 | u'Paul Logston', 'manual'), 143 | ] 144 | 145 | 146 | # -- Options for manual page output --------------------------------------- 147 | 148 | # One entry per manual page. List of tuples 149 | # (source start file, name, description, authors, manual section). 150 | man_pages = [ 151 | (master_doc, 'django-scim2', u'django-scim2 Documentation', 152 | [author], 1) 153 | ] 154 | 155 | 156 | # -- Options for Texinfo output ------------------------------------------- 157 | 158 | # Grouping the document tree into Texinfo files. List of tuples 159 | # (source start file, target name, title, author, 160 | # dir menu entry, description, category) 161 | texinfo_documents = [ 162 | (master_doc, 'django-scim2', u'django-scim2 Documentation', 163 | author, 'django-scim2', 'One line description of project.', 164 | 'Miscellaneous'), 165 | ] 166 | 167 | # Example configuration for intersphinx: refer to the Python standard library. 168 | intersphinx_mapping = {"python": ('https://docs.python.org/', None)} 169 | -------------------------------------------------------------------------------- /docs/filters.rst: -------------------------------------------------------------------------------- 1 | Filters 2 | ======= 3 | 4 | .. automodule:: django_scim.filters 5 | :members: 6 | 7 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-scim2 documentation master file, created by 2 | sphinx-quickstart on Wed Dec 14 17:41:32 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | .. include:: ../README.rst 8 | 9 | Contents 10 | ======== 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | :caption: Modules 15 | 16 | adapters 17 | filters 18 | models 19 | utils 20 | views 21 | settings 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=django-scim2 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/models.rst: -------------------------------------------------------------------------------- 1 | Models 2 | ====== 3 | 4 | .. automodule:: django_scim.models 5 | :members: 6 | 7 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | django>=4.2,<5.2 2 | django-scim2 # not pinning, should always work with latest release 3 | Sphinx>=8,<9 4 | sphinx-rtd-theme -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | USER_ADAPTER 5 | Default: 'django_scim.adapters.SCIMUser' 6 | 7 | GROUP_MODEL 8 | Default: 'django.contrib.auth.models.Group' 9 | 10 | GROUP_ADAPTER 11 | Default: 'django_scim.adapters.SCIMGroup' 12 | 13 | USER_FILTER_PARSER 14 | Default: 'django_scim.filters.UserFilterQuery' 15 | 16 | GROUP_FILTER_PARSER 17 | Default: 'django_scim.filters.GroupFilterQuery' 18 | 19 | SERVICE_PROVIDER_CONFIG_MODEL 20 | Default: 'django_scim.models.SCIMServiceProviderConfig' 21 | 22 | BASE_LOCATION_GETTER 23 | Default: 'django_scim.utils.default_base_scim_location_getter' 24 | 25 | GET_EXTRA_MODEL_FILTER_KWARGS_GETTER 26 | Default: 'django_scim.utils.default_get_extra_model_filter_kwargs_getter' 27 | 28 | GET_EXTRA_MODEL_EXCLUDE_KWARGS_GETTER 29 | Default: 'django_scim.utils.default_get_extra_model_exclude_kwargs_getter' 30 | 31 | GET_OBJECT_POST_PROCESSOR_GETTER 32 | Default: 'django_scim.utils.default_get_object_post_processor_getter' 33 | 34 | GET_QUERYSET_POST_PROCESSOR_GETTER 35 | Default: 'django_scim.utils.default_get_queryset_post_processor_getter' 36 | 37 | SCHEMAS_GETTER 38 | Default: 'django_scim.schemas.default_schemas_getter' 39 | 40 | DOCUMENTATION_URI 41 | Default: None 42 | 43 | SCHEME 44 | Default: 'https' 45 | 46 | NETLOC 47 | Default: None 48 | 49 | EXPOSE_SCIM_EXCEPTIONS 50 | Default: False 51 | 52 | In some circumstances it can be beneficial for the client 53 | to know what caused an error. However, this can present an 54 | unacceptable security risk for many companies. This flag 55 | allows for a generic error message to be returned when such a 56 | security risk is unacceptable. 57 | 58 | AUTHENTICATION_SCHEMES 59 | Default: [] 60 | 61 | WWW_AUTHENTICATE_HEADER 62 | Default: 'Basic realm="django-scim2"' 63 | -------------------------------------------------------------------------------- /docs/utils.rst: -------------------------------------------------------------------------------- 1 | Utilities 2 | ========= 3 | 4 | .. automodule:: django_scim.utils 5 | :members: 6 | 7 | -------------------------------------------------------------------------------- /docs/views.rst: -------------------------------------------------------------------------------- 1 | Views 2 | ===== 3 | 4 | .. automodule:: django_scim.views 5 | :members: 6 | 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-scim2" 3 | version = "0.19.1" 4 | description = "A partial implementation of the SCIM 2.0 provider specification for use with Django." 5 | license = "MIT" 6 | authors = ["Paul Logston "] 7 | maintainers = ["Devs "] 8 | readme = "README.rst" 9 | homepage = "https://pypi.org/project/django-scim2/" 10 | repository = "https://github.com/15five/django-scim2" 11 | documentation = "https://django-scim2.readthedocs.io/en/stable/" 12 | keywords = ["django", "scim", "scim2", "2.0"] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Environment :: Web Environment", 16 | "Framework :: Django", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Natural Language :: English", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Topic :: Internet", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | ] 29 | packages = [ 30 | { include = "django_scim", from = "src" }, 31 | ] 32 | 33 | [tool.poetry.dependencies] 34 | python = ">=3.9" 35 | scim2-filter-parser = ">=0.5.0" 36 | django = ">=4.2" 37 | 38 | [tool.poetry.dev-dependencies] 39 | mock = "^5.1.0" 40 | tox = "^4.25.0" 41 | flake8 = "^7.2.0" 42 | toml = "^0.10.1" 43 | flake8-isort = "^6.1.2" 44 | pytest = ">=5.4.0" 45 | pytest-django = "4.11.1" 46 | coverage = "^7.8.2" 47 | pytest-cov = "6.0.0" 48 | 49 | [tool.black] 50 | line-length = 100 51 | skip-string-normalization = true 52 | 53 | [build-system] 54 | requires = ["poetry-core>=1.5.2"] 55 | build-backend = "poetry.core.masonry.api" 56 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | python_files = tests/test_*.py 4 | django_find_project = false 5 | pythonpath = . src 6 | -------------------------------------------------------------------------------- /src/django_scim/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/15five/django-scim2/078e85f034b9e083d4a98437277ac2f9fd49ae1d/src/django_scim/__init__.py -------------------------------------------------------------------------------- /src/django_scim/adapters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Adapters are used to convert the data model described by the SCIM 2.0 3 | specification to a data model that fits the data provided by the application 4 | implementing a SCIM api. 5 | 6 | For example, in a Django app, there are User and Group models that do 7 | not have the same attributes/fields that are defined by the SCIM 2.0 8 | specification. The Django User model has both ``first_name`` and ``last_name`` 9 | attributes but the SCIM speicifcation requires this same data be sent under 10 | the names ``givenName`` and ``familyName`` respectively. 11 | 12 | An adapter is instantiated with a model instance. Eg:: 13 | 14 | user = get_user_model().objects.get(id=1) 15 | scim_user = SCIMUser(user) 16 | ... 17 | 18 | """ 19 | from typing import Optional, Union 20 | from urllib.parse import urljoin 21 | 22 | from django import core 23 | from django.urls import reverse 24 | from scim2_filter_parser.attr_paths import AttrPath 25 | 26 | from . import constants, exceptions 27 | from .utils import ( 28 | get_base_scim_location_getter, 29 | get_group_adapter, 30 | get_group_filter_parser, 31 | get_user_adapter, 32 | get_user_filter_parser, 33 | get_user_model, 34 | ) 35 | 36 | 37 | class SCIMMixin(object): 38 | 39 | ATTR_MAP = {} 40 | 41 | id_field = 'scim_id' # Modifiable by overriding classes 42 | 43 | def __init__(self, obj, request=None): 44 | self.obj = obj 45 | self._request = request 46 | 47 | @property 48 | def request(self): 49 | if self._request: 50 | return self._request 51 | 52 | raise RuntimeError('Adapter is not associated with a request object. ' 53 | 'Set object.request to avoid this error.') 54 | 55 | @request.setter 56 | def request(self, value): 57 | self._request = value 58 | 59 | @property 60 | def id(self): 61 | return str(getattr(self.obj, self.id_field)) 62 | 63 | @property 64 | def path(self): 65 | return reverse(self.url_name, kwargs={'uuid': self.id}) 66 | 67 | @property 68 | def location(self): 69 | return urljoin(get_base_scim_location_getter()(self.request), self.path) 70 | 71 | def to_dict(self): 72 | """ 73 | Return a ``dict`` conforming to the object's SCIM Schema, 74 | ready for conversion to a JSON object. 75 | """ 76 | d = { 77 | 'id': self.id, 78 | 'externalId': self.obj.scim_external_id, 79 | } 80 | 81 | return d 82 | 83 | def validate_dict(self, d): 84 | """ 85 | Validate dict from SCIM call. 86 | 87 | Currently this method only validates: 88 | - the most common attributes 89 | - attributes against their expected types 90 | """ 91 | for key, value in d.items(): 92 | expected_type = { 93 | 'active': bool, 94 | }.get(key) 95 | 96 | if expected_type and not isinstance(value, expected_type): 97 | raise exceptions.BadRequestError( 98 | f'''"{key}" should be of type "{expected_type.__name__}". ''' 99 | f'''Got type "{type(value).__name__}"''' 100 | ) 101 | 102 | def from_dict(self, d): 103 | """ 104 | Consume a ``dict`` conforming to the object's SCIM Schema, updating the 105 | internal object with data from the ``dict``. 106 | 107 | This method is overridden and called by subclass adapters. Please make 108 | changes there. 109 | """ 110 | scim_external_id = d.get('externalId') 111 | self.obj.scim_external_id = scim_external_id or '' 112 | 113 | def save(self): 114 | self.obj.save() 115 | 116 | def delete(self): 117 | self.obj.__class__.objects.filter(id=self.id).delete() 118 | 119 | def handle_operations(self, operations): 120 | """ 121 | The SCIM specification allows for making changes to specific attributes 122 | of a model. These changes are sent in PATCH requests and are batched into 123 | operations to be performed on a object. Operations can have an op code 124 | of 'add', 'remove', or 'replace'. This method iterates through all of the 125 | operations in ``operations`` and calls the appropriate handler (defined 126 | on the appropriate adapter) for each. 127 | 128 | Django-scim2 only provides a partial implementation of PATCH call 129 | handlers. The RFC (https://tools.ietf.org/html/rfc7644#section-3.5.2) 130 | specifies a number of requirements for a full PATCH implementation. 131 | This implementation does not meet all of those requirements. For 132 | example, these are some features that have been left out. 133 | 134 | Add Operations: 135 | - If the target location does not exist, the attribute and value 136 | are added. 137 | Remove Operations: 138 | - If the target location is a multi-valued attribute and a complex 139 | filter is specified comparing a "value", the values matched by the 140 | filter are removed. If no other values remain after removal of 141 | the selected values, the multi-valued attribute SHALL be 142 | considered unassigned. 143 | Replace Operations: 144 | - If the target location path specifies an attribute that does not 145 | exist, the service provider SHALL treat the operation as an "add". 146 | """ 147 | for operation in operations: 148 | path = operation.get('path') 149 | value = operation.get('value') 150 | 151 | paths_and_values = self.parse_path_and_values(path, value) 152 | 153 | for path, value in paths_and_values: 154 | self.handle_path_and_value(path, value, operation) 155 | 156 | def handle_path_and_value(self, 157 | path: AttrPath, 158 | value: Union[str, list, dict], 159 | operation: dict): 160 | op_code = operation.get('op').lower() 161 | 162 | if op_code not in constants.VALID_PATCH_OPS: 163 | raise exceptions.BadRequestError(f'Unknown PATCH op "{op_code}"') 164 | 165 | if op_code == 'remove' and not path: 166 | msg = '"path" must be specified during "remove" PATCH calls' 167 | raise exceptions.BadRequestError(msg, scim_type='noTarget') 168 | 169 | validate_method = 'validate_op_' + op_code 170 | handler = getattr(self, validate_method, self._default_validate_op) 171 | if handler: 172 | handler(path, value, operation) 173 | 174 | handle_method = 'handle_' + op_code 175 | handler = getattr(self, handle_method) 176 | handler(path, value, operation) 177 | 178 | def _default_validate_op(self, 179 | path: Optional[AttrPath], 180 | value: Union[str, list, dict], 181 | operation: dict): 182 | """ 183 | Validate the operation. 184 | 185 | Currently this method only validates: 186 | - the most common attributes 187 | - simple paths 188 | - attributes against their expected types 189 | """ 190 | expected_type = None 191 | if path and not path.is_complex: 192 | expected_type = { 193 | ('active', None, None): bool, 194 | }.get(path.first_path) 195 | 196 | if expected_type and not isinstance(value, expected_type): 197 | raise exceptions.BadRequestError( 198 | f'''"{operation['path']}" should be of type "{expected_type.__name__}". ''' 199 | f'''Got type "{type(value).__name__}"''' 200 | ) 201 | 202 | def parse_path_and_values(self, 203 | path: Optional[str], 204 | value: Union[str, list, dict]) -> list: 205 | """ 206 | Return new paths and values given original paths and values. 207 | 208 | This method can be overridden to provide a more usable path and value 209 | within the associated handle methods. 210 | """ 211 | paths_and_values = [] 212 | # Convert all path's to AttrPath objects in preparation for 213 | # use of scim2-filter-parser. Complex paths can path through as the 214 | # logic to handle them is not in place yet. 215 | if not path: 216 | if not isinstance(value, dict): 217 | raise ValueError('No path and operation value is a non-dict. Can not determine attribute.') 218 | 219 | # If there is no path and value is a dict, we assume that each 220 | # key in the dict is an attribute path. Let's convert attribute 221 | # paths to AttrPath objects to have a uniform API. 222 | for path, value in value.items(): 223 | new_path = self.split_path(path) 224 | new_value = value 225 | paths_and_values.append((new_path, new_value)) 226 | 227 | else: 228 | new_path = self.split_path(path) 229 | new_value = value 230 | paths_and_values.append((new_path, new_value)) 231 | 232 | return paths_and_values 233 | 234 | def split_path(self, path: str) -> AttrPath: 235 | """ 236 | Convert string path to an AttrPath object if possible. 237 | 238 | An AttrPath can be complex. Eg:: 239 | 240 | - "addresses[type eq "work"]" 241 | - "members[value eq "123"].displayName" 242 | - "emails[type eq "work" and value co "@example.com"].value" 243 | 244 | It's up to the handlers to reject, ignore, handle requests with 245 | these types of paths. Handling them is above and beyond what 246 | the maintainer has time for. 247 | """ 248 | # AttrPath requires a complete filter query. Thus we tack on 249 | # ' eq ""' to path to make a complete SCIM query. 250 | filter_ = path + ' eq ""' 251 | attr_path = AttrPath(filter_, self.ATTR_MAP) 252 | 253 | if not list(attr_path): 254 | msg = 'No attribute path found in request' 255 | raise exceptions.BadRequestError(msg) 256 | 257 | return attr_path 258 | 259 | def handle_add(self, 260 | path: AttrPath, 261 | value: Union[str, list, dict], 262 | operation: dict): 263 | """ 264 | Handle add operations per: 265 | https://tools.ietf.org/html/rfc7644#section-3.5.2.1 266 | """ 267 | raise exceptions.NotImplementedError 268 | 269 | def handle_remove(self, 270 | path: AttrPath, 271 | value: Union[str, list, dict], 272 | operation: dict): 273 | """ 274 | Handle remove operations per: 275 | https://tools.ietf.org/html/rfc7644#section-3.5.2.2 276 | """ 277 | raise exceptions.NotImplementedError 278 | 279 | def handle_replace(self, 280 | path: AttrPath, 281 | value: Union[str, list, dict], 282 | operation: dict): 283 | """ 284 | Handle replace operations per: 285 | https://tools.ietf.org/html/rfc7644#section-3.5.2.3 286 | """ 287 | raise exceptions.NotImplementedError 288 | 289 | 290 | class SCIMUser(SCIMMixin): 291 | """ 292 | Adapter for adding SCIM functionality to a Django User object. 293 | 294 | This adapter can be overridden; see the ``USER_ADAPTER`` setting 295 | for details. 296 | """ 297 | # not great, could be more decoupled. But \__( )__/ whatevs. 298 | url_name = 'scim:users' 299 | resource_type = 'User' 300 | 301 | ATTR_MAP = get_user_filter_parser().attr_map 302 | 303 | @property 304 | def display_name(self): 305 | """ 306 | Return the displayName of the user per the SCIM spec. 307 | """ 308 | if self.obj.first_name and self.obj.last_name: 309 | return u'{0.first_name} {0.last_name}'.format(self.obj) 310 | return self.obj.username 311 | 312 | @property 313 | def name_formatted(self): 314 | return self.display_name 315 | 316 | @property 317 | def emails(self): 318 | """ 319 | Return the email of the user per the SCIM spec. 320 | """ 321 | return [{'value': self.obj.email, 'primary': True}] 322 | 323 | @property 324 | def groups(self): 325 | """ 326 | Return the groups of the user per the SCIM spec. 327 | """ 328 | group_qs = self.obj.scim_groups.all() 329 | scim_groups = [get_group_adapter()(g, self.request) for g in group_qs] 330 | 331 | dicts = [] 332 | for group in scim_groups: 333 | d = { 334 | 'value': group.id, 335 | '$ref': group.location, 336 | 'display': group.display_name, 337 | } 338 | dicts.append(d) 339 | 340 | return dicts 341 | 342 | @property 343 | def meta(self): 344 | """ 345 | Return the meta object of the user per the SCIM spec. 346 | """ 347 | d = { 348 | 'resourceType': self.resource_type, 349 | 'created': self.obj.date_joined.isoformat(), 350 | 'lastModified': self.obj.date_joined.isoformat(), 351 | 'location': self.location, 352 | } 353 | 354 | return d 355 | 356 | def to_dict(self): 357 | """ 358 | Return a ``dict`` conforming to the SCIM User Schema, 359 | ready for conversion to a JSON object. 360 | """ 361 | d = super().to_dict() 362 | d.update({ 363 | 'schemas': [constants.SchemaURI.USER], 364 | 'userName': self.obj.username, 365 | 'name': { 366 | 'givenName': self.obj.first_name, 367 | 'familyName': self.obj.last_name, 368 | 'formatted': self.name_formatted, 369 | }, 370 | 'displayName': self.display_name, 371 | 'emails': self.emails, 372 | 'active': self.obj.is_active, 373 | 'groups': self.groups, 374 | 'meta': self.meta, 375 | }) 376 | 377 | return d 378 | 379 | def from_dict(self, d): 380 | """ 381 | Consume a ``dict`` conforming to the SCIM User Schema, updating the 382 | internal user object with data from the ``dict``. 383 | 384 | Please note, the user object is not saved within this method. To 385 | persist the changes made by this method, please call ``.save()`` on the 386 | adapter. Eg:: 387 | 388 | scim_user.from_dict(d) 389 | scim_user.save() 390 | """ 391 | super().from_dict(d) 392 | 393 | username = d.get('userName') 394 | self.obj.username = username or '' 395 | 396 | self.obj.scim_username = self.obj.username 397 | 398 | first_name = d.get('name', {}).get('givenName') 399 | self.obj.first_name = first_name or '' 400 | 401 | last_name = d.get('name', {}).get('familyName') 402 | self.obj.last_name = last_name or '' 403 | 404 | emails = d.get('emails', []) 405 | self.parse_emails(emails) 406 | 407 | cleartext_password = d.get('password') 408 | if cleartext_password: 409 | self.obj.set_password(cleartext_password) 410 | 411 | active = d.get('active') 412 | if active is not None: 413 | self.obj.is_active = active 414 | 415 | @classmethod 416 | def resource_type_dict(cls, request=None): 417 | """ 418 | Return a ``dict`` containing ResourceType metadata for the user object. 419 | """ 420 | id_ = cls.resource_type 421 | path = reverse('scim:resource-types', kwargs={'uuid': id_}) 422 | location = urljoin(get_base_scim_location_getter()(request), path) 423 | return { 424 | 'schemas': [constants.SchemaURI.RESOURCE_TYPE], 425 | 'id': id_, 426 | 'name': 'User', 427 | 'endpoint': reverse('scim:users'), 428 | 'description': 'User Account', 429 | 'schema': constants.SchemaURI.USER, 430 | 'meta': { 431 | 'location': location, 432 | 'resourceType': 'ResourceType' 433 | } 434 | } 435 | 436 | def parse_emails(self, value: Optional[list]): 437 | if value: 438 | email = None 439 | if isinstance(value, list): 440 | primary_emails = sorted( 441 | (e for e in value if e.get('primary')), 442 | key=lambda d: d.get('value') 443 | ) 444 | secondary_emails = sorted( 445 | (e for e in value if not e.get('primary')), 446 | key=lambda d: d.get('value') 447 | ) 448 | 449 | emails = primary_emails + secondary_emails 450 | if emails: 451 | email = emails[0].get('value') 452 | else: 453 | raise exceptions.BadRequestError('Invalid email value') 454 | 455 | elif isinstance(value, dict): 456 | # if value is a dict, let's assume it contains the primary email. 457 | # OneLogin sends a dict despite the spec: 458 | # https://tools.ietf.org/html/rfc7643#section-4.1.2 459 | # https://tools.ietf.org/html/rfc7643#section-8.2 460 | email = (value.get('value') or '').strip() 461 | 462 | self.validate_email(email) 463 | 464 | self.obj.email = email 465 | 466 | @staticmethod 467 | def validate_email(email): 468 | try: 469 | core.validators.EmailValidator()(email) 470 | except core.exceptions.ValidationError: 471 | raise exceptions.BadRequestError('Invalid email value') 472 | 473 | def handle_replace(self, 474 | path: Optional[AttrPath], 475 | value: Union[str, list, dict], 476 | operation: dict): 477 | """ 478 | Handle the replace operations. 479 | """ 480 | if not isinstance(value, dict): 481 | # Restructure for use in loop below. 482 | value = {path: value} 483 | 484 | if not isinstance(value, dict): 485 | raise exceptions.NotImplementedError( 486 | f'PATCH replace operation with value type of ' 487 | f'{type(value)} is not implemented' 488 | ) 489 | 490 | for path, value in (value or {}).items(): 491 | if path.first_path in self.ATTR_MAP: 492 | setattr(self.obj, self.ATTR_MAP.get(path.first_path), value) 493 | 494 | elif path.first_path == ('emails', None, None): 495 | self.parse_emails(value) 496 | 497 | else: 498 | raise exceptions.NotImplementedError('Not Implemented') 499 | 500 | self.save() 501 | 502 | 503 | class SCIMGroup(SCIMMixin): 504 | """ 505 | Adapter for adding SCIM functionality to a Django Group object. 506 | 507 | This adapter can be overridden; see the ``GROUP_ADAPTER`` 508 | setting for details. 509 | """ 510 | # not great, could be more decoupled. But \__( )__/ whatevs. 511 | url_name = 'scim:groups' 512 | resource_type = 'Group' 513 | 514 | ATTR_MAP = get_group_filter_parser().attr_map 515 | 516 | @property 517 | def display_name(self): 518 | """ 519 | Return the displayName of the group per the SCIM spec. 520 | """ 521 | return self.obj.name 522 | 523 | @property 524 | def members(self): 525 | """ 526 | Return a list of user dicts (ready for serialization) for the members 527 | of the group. 528 | 529 | :rtype: list 530 | """ 531 | users = self.obj.user_set.all() 532 | scim_users = [get_user_adapter()(user, self.request) for user in users] 533 | 534 | dicts = [] 535 | for user in scim_users: 536 | d = { 537 | 'value': user.id, 538 | '$ref': user.location, 539 | 'display': user.display_name, 540 | } 541 | dicts.append(d) 542 | 543 | return dicts 544 | 545 | @property 546 | def meta(self): 547 | """ 548 | Return the meta object of the group per the SCIM spec. 549 | """ 550 | d = { 551 | 'resourceType': self.resource_type, 552 | 'location': self.location, 553 | } 554 | 555 | return d 556 | 557 | def to_dict(self): 558 | """ 559 | Return a ``dict`` conforming to the SCIM Group Schema, 560 | ready for conversion to a JSON object. 561 | """ 562 | d = super().to_dict() 563 | d.update({ 564 | 'schemas': [constants.SchemaURI.GROUP], 565 | 'displayName': self.display_name, 566 | 'members': self.members, 567 | 'meta': self.meta, 568 | }) 569 | return d 570 | 571 | def from_dict(self, d): 572 | """ 573 | Consume a ``dict`` conforming to the SCIM Group Schema, updating the 574 | internal group object with data from the ``dict``. 575 | 576 | Please note, the group object is not saved within this method. To 577 | persist the changes made by this method, please call ``.save()`` on the 578 | adapter. Eg:: 579 | 580 | scim_group.from_dict(d) 581 | scim_group.save() 582 | """ 583 | super().from_dict(d) 584 | 585 | name = d.get('displayName') 586 | self.obj.name = name or '' 587 | 588 | @classmethod 589 | def resource_type_dict(cls, request=None): 590 | """ 591 | Return a ``dict`` containing ResourceType metadata for the group object. 592 | """ 593 | id_ = cls.resource_type 594 | path = reverse('scim:resource-types', kwargs={'uuid': id_}) 595 | location = urljoin(get_base_scim_location_getter()(request), path) 596 | return { 597 | 'schemas': [constants.SchemaURI.RESOURCE_TYPE], 598 | 'id': id_, 599 | 'name': 'Group', 600 | 'endpoint': reverse('scim:groups'), 601 | 'description': 'Group', 602 | 'schema': constants.SchemaURI.GROUP, 603 | 'meta': { 604 | 'location': location, 605 | 'resourceType': 'ResourceType' 606 | } 607 | } 608 | 609 | def handle_add(self, path, value, operation): 610 | """ 611 | Handle add operations. 612 | """ 613 | if path.first_path == ('members', None, None): 614 | members = value or [] 615 | ids = [int(member.get('value')) for member in members] 616 | users = get_user_model().objects.filter(id__in=ids) 617 | 618 | if len(ids) != users.count(): 619 | raise exceptions.BadRequestError('Can not add a non-existent user to group') 620 | 621 | for user in users: 622 | self.obj.user_set.add(user) 623 | 624 | else: 625 | raise exceptions.NotImplementedError 626 | 627 | def handle_remove(self, path, value, operation): 628 | """ 629 | Handle remove operations. 630 | """ 631 | if path.first_path == ('members', None, None): 632 | members = value or [] 633 | ids = [int(member.get('value')) for member in members] 634 | users = get_user_model().objects.filter(id__in=ids) 635 | 636 | if len(ids) != users.count(): 637 | raise exceptions.BadRequestError('Can not remove a non-existent user from group') 638 | 639 | for user in users: 640 | self.obj.user_set.remove(user) 641 | 642 | else: 643 | raise exceptions.NotImplementedError 644 | 645 | def handle_replace(self, path, value, operation): 646 | """ 647 | Handle the replace operations. 648 | """ 649 | if path.first_path == ('name', None, None): 650 | name = value[0].get('value') 651 | self.obj.name = name 652 | self.save() 653 | 654 | else: 655 | raise exceptions.NotImplementedError 656 | -------------------------------------------------------------------------------- /src/django_scim/constants.py: -------------------------------------------------------------------------------- 1 | ENCODING = 'utf-8' 2 | SCIM_CONTENT_TYPE = 'application/scim+json' 3 | VALID_PATCH_OPS = ('add', 'remove', 'replace') 4 | 5 | 6 | class SchemaURI(object): 7 | ERROR = 'urn:ietf:params:scim:api:messages:2.0:Error' 8 | LIST_RESPONSE = 'urn:ietf:params:scim:api:messages:2.0:ListResponse' 9 | SERACH_REQUEST = 'urn:ietf:params:scim:api:messages:2.0:SearchRequest' 10 | NOT_SERACH_REQUEST = 'urn:ietf:params:scim:api:messages:2.0:NotSearchRequest' 11 | PATCH_OP = 'urn:ietf:params:scim:api:messages:2.0:PatchOp' 12 | 13 | USER = 'urn:ietf:params:scim:schemas:core:2.0:User' 14 | ENTERPRISE_URN = 'urn:ietf:params:scim:schemas:extension:enterprise' 15 | ENTERPRISE_USER = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' 16 | GROUP = 'urn:ietf:params:scim:schemas:core:2.0:Group' 17 | RESOURCE_TYPE = 'urn:ietf:params:scim:schemas:core:2.0:ResourceType' 18 | SERVICE_PROVIDER_CONFIG = 'urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig' 19 | -------------------------------------------------------------------------------- /src/django_scim/exceptions.py: -------------------------------------------------------------------------------- 1 | from .constants import SchemaURI 2 | 3 | 4 | class SCIMException(Exception): 5 | status = 500 6 | schema = SchemaURI.ERROR 7 | scim_type = None 8 | 9 | def __init__(self, detail=None, **kwargs): 10 | self.detail = detail or '' 11 | self.status = kwargs.get('status') or self.status 12 | self.schemas = kwargs.get('schemas') or [self.schema] 13 | self.scim_type = kwargs.get('scim_type') or self.scim_type 14 | 15 | msg = '({} {}) {}'.format(self.status, self.scim_type, self.detail) 16 | 17 | super(Exception, self).__init__(msg) 18 | 19 | def to_dict(self): 20 | d = { 21 | 'schemas': self.schemas, 22 | 'detail': self.detail, 23 | 'status': self.status, 24 | } 25 | if self.scim_type: 26 | d['scimType'] = self.scim_type 27 | 28 | return d 29 | 30 | 31 | class AuthorizationError(SCIMException): 32 | status = 401 33 | 34 | 35 | class NotFoundError(SCIMException): 36 | status = 404 37 | 38 | def __init__(self, uuid, **kwargs): 39 | detail = u'Resource {} not found'.format(uuid) 40 | super(NotFoundError, self).__init__(detail, **kwargs) 41 | 42 | 43 | class BadRequestError(SCIMException): 44 | status = 400 45 | 46 | 47 | class IntegrityError(SCIMException): 48 | status = 409 49 | 50 | 51 | class NotImplementedError(SCIMException): 52 | status = 501 53 | -------------------------------------------------------------------------------- /src/django_scim/filters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Transform filter query into QuerySet 3 | """ 4 | from scim2_filter_parser.queries.sql import SQLQuery 5 | 6 | from .utils import get_group_model, get_user_model 7 | 8 | 9 | class FilterQuery: 10 | model_getter = None 11 | joins = () 12 | attr_map = None 13 | query_class = SQLQuery 14 | 15 | @classmethod 16 | def table_name(cls): 17 | return cls.model_getter()._meta.db_table 18 | 19 | @classmethod 20 | def search(cls, filter_query, request=None): 21 | q = cls.query_class(filter_query, cls.table_name(), cls.attr_map, cls.joins) 22 | if q.where_sql is None: 23 | return cls.model_getter().objects.none() 24 | 25 | sql, params = cls.get_raw_args(q, request) 26 | 27 | return cls.model_getter().objects.raw(sql, params) 28 | 29 | @classmethod 30 | def get_raw_args(cls, q, request=None): 31 | """ 32 | Return a Query object's SQL augmented with params from cls.get_extras. 33 | """ 34 | sql, params = q.sql, q.params 35 | 36 | extra_sql, extra_params = cls.get_extras(q, request) 37 | if extra_sql: 38 | if "'%s'" in extra_sql: 39 | raise ValueError( 40 | 'Dangerous use of quotes around place holder. Please see ' 41 | 'https://docs.djangoproject.com/en/2.2/ref/models/querysets/#extra ' 42 | 'for more details.' 43 | ) 44 | 45 | sql = sql.rstrip(';') + extra_sql + ';' 46 | params += extra_params 47 | 48 | return sql, params 49 | 50 | @classmethod 51 | def get_extras(cls, q, request=None) -> (str, list): 52 | """ 53 | Return extra SQL and params to be attached to end of current Query's 54 | SQL and params. 55 | 56 | For example: 57 | return 'AND tenant_id = %s', [request.user.tenant_id] 58 | """ 59 | return '', [] 60 | 61 | 62 | class UserFilterQuery(FilterQuery): 63 | model_getter = get_user_model 64 | attr_map = { 65 | # attr, sub attr, uri 66 | ('userName', None, None): 'username', 67 | ('name', 'familyName', None): 'last_name', 68 | ('familyName', None, None): 'last_name', 69 | ('name', 'givenName', None): 'first_name', 70 | ('givenName', None, None): 'first_name', 71 | ('active', None, None): 'is_active', 72 | } 73 | 74 | 75 | class GroupFilterQuery(FilterQuery): 76 | model_getter = get_group_model 77 | attr_map = {} 78 | -------------------------------------------------------------------------------- /src/django_scim/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.http.response import HttpResponse 4 | from django.urls import reverse 5 | 6 | from . import constants 7 | from .settings import scim_settings 8 | from .utils import get_is_authenticated_predicate, get_loggable_body 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class SCIMAuthCheckMiddleware(object): 14 | """ 15 | Check to see if a prior middleware has logged the user in. 16 | 17 | This middleware should be place after auth middleware used to login a user. 18 | """ 19 | 20 | def __init__(self, get_response=None): 21 | # One-time configuration and initialization per server start. 22 | self.get_response = get_response 23 | 24 | def __call__(self, request, *args, **kwargs): 25 | response = None 26 | if hasattr(self, 'process_request'): 27 | response = self.process_request(request) 28 | if not response: 29 | response = self.get_response(request, *args, **kwargs) 30 | if hasattr(self, 'process_response'): 31 | response = self.process_response(request, response) 32 | return response 33 | 34 | @property 35 | def reverse_url(self): 36 | if not hasattr(self, '_reverse_url'): 37 | self._reverse_url = reverse('scim:root') 38 | return self._reverse_url 39 | 40 | def should_log_request(self, request): 41 | """ 42 | Return True if request should be logged. 43 | """ 44 | return request.path.startswith(self.reverse_url) 45 | 46 | def process_request(self, request): 47 | if self.should_log_request(request): 48 | self.log_request(request) 49 | # If we've just passed through the auth middleware and there is no user 50 | # associated with the request we can assume permission 51 | # was denied and return a 401. 52 | if not hasattr(request, 'user') or not get_is_authenticated_predicate()(request.user): 53 | if request.path.startswith(self.reverse_url): 54 | response = HttpResponse(status=401) 55 | response['WWW-Authenticate'] = scim_settings.WWW_AUTHENTICATE_HEADER 56 | return response 57 | 58 | def process_response(self, request, response): 59 | if self.should_log_request(request): 60 | self.log_response(request, response) 61 | return response 62 | 63 | def get_loggable_content(self, content): 64 | try: 65 | body = get_loggable_body(content.decode(constants.ENCODING)) 66 | except Exception as e: 67 | body = 'Could not parse request body\n' + str(e) 68 | 69 | return body 70 | 71 | def get_loggable_request_message(self, request): 72 | body = self.get_loggable_content(request.body) 73 | parts = [ 74 | 'PATH', 75 | request.path, 76 | 'METHOD', 77 | request.method, 78 | 'BODY', 79 | body, 80 | ] 81 | 82 | return '\n'.join(parts) 83 | 84 | def get_loggable_response_message(self, request, response): 85 | body = self.get_loggable_content(response.content) 86 | parts = [ 87 | 'PATH', 88 | request.path, 89 | 'METHOD', 90 | request.method, 91 | 'BODY', 92 | body, 93 | 'STATUS_CODE', 94 | str(response.status_code), 95 | ] 96 | 97 | return '\n'.join(parts) 98 | 99 | def log_request(self, request): 100 | message = self.get_loggable_request_message(request) 101 | logger.debug(message) 102 | 103 | def log_response(self, request, response): 104 | message = self.get_loggable_response_message(request, response) 105 | logger.debug(message) 106 | -------------------------------------------------------------------------------- /src/django_scim/models.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | from django.db import models 4 | from django.urls import reverse 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from . import constants, exceptions 8 | from .settings import scim_settings 9 | from .utils import get_base_scim_location_getter 10 | 11 | 12 | class SCIMServiceProviderConfig(object): 13 | """ 14 | A reference ServiceProviderConfig. This should be overridden to 15 | describe those authentication_schemes and features that are implemented by 16 | your app. 17 | """ 18 | 19 | def __init__(self, request=None): 20 | self.request = request 21 | 22 | @property 23 | def meta(self): 24 | return { 25 | 'location': self.location, 26 | 'resourceType': 'ServiceProviderConfig', 27 | } 28 | 29 | @property 30 | def location(self): 31 | path = reverse('scim:service-provider-config') 32 | return urljoin(get_base_scim_location_getter()(self.request), path) 33 | 34 | def to_dict(self): 35 | return { 36 | 'schemas': [constants.SchemaURI.SERVICE_PROVIDER_CONFIG], 37 | 'documentationUri': scim_settings.DOCUMENTATION_URI, 38 | 'patch': { 39 | 'supported': True, 40 | }, 41 | 'bulk': { 42 | 'supported': False, 43 | 'maxOperations': 1000, 44 | 'maxPayloadSize': 1048576, 45 | }, 46 | # Django-SCIM2 does not fully support the SCIM2.0 filtering spec. 47 | # Until it does, let's under promise and over deliver to the world. 48 | 'filter': { 49 | 'supported': False, 50 | 'maxResults': 50, 51 | }, 52 | 'changePassword': { 53 | 'supported': True, 54 | }, 55 | 'sort': { 56 | 'supported': False, 57 | }, 58 | 'etag': { 59 | 'supported': False, 60 | }, 61 | 'authenticationSchemes': scim_settings.AUTHENTICATION_SCHEMES, 62 | 'meta': self.meta, 63 | } 64 | 65 | 66 | class AbstractSCIMCommonAttributesMixin(models.Model): 67 | """ 68 | An abstract model to provide SCIM Common Attributes. 69 | 70 | https://tools.ietf.org/html/rfc7643#section-3.1 71 | 72 | Each SCIM resource (Users, Groups, etc.) includes the following 73 | common attributes. With the exception of the "ServiceProviderConfig" 74 | and "ResourceType" server discovery endpoints and their associated 75 | resources, these attributes MUST be defined for all resources, 76 | including any extended resource types. When accepted by a service 77 | provider (e.g., after a SCIM create), the attributes "id" and "meta" 78 | (and its associated sub-attributes) MUST be assigned values by the 79 | service provider. Common attributes are considered to be part of 80 | every base resource schema and do not use their own "schemas" URI. 81 | 82 | For backward compatibility, some existing schema definitions MAY list 83 | common attributes as part of the schema. The attribute 84 | characteristics (see Section 2.2) listed here SHALL take precedence 85 | over older definitions that may be included in existing schemas. 86 | """ 87 | 88 | """ 89 | id 90 | A unique identifier for a SCIM resource as defined by the service 91 | provider. Each representation of the resource MUST include a 92 | non-empty "id" value. This identifier MUST be unique across the 93 | SCIM service provider's entire set of resources. It MUST be a 94 | stable, non-reassignable identifier that does not change when the 95 | same resource is returned in subsequent requests. The value of 96 | the "id" attribute is always issued by the service provider and 97 | MUST NOT be specified by the client. The string "bulkId" is a 98 | reserved keyword and MUST NOT be used within any unique identifier 99 | value. The attribute characteristics are "caseExact" as "true", a 100 | mutability of "readOnly", and a "returned" characteristic of 101 | "always". See Section 9 for additional considerations regarding 102 | privacy. 103 | """ 104 | scim_id = models.CharField( 105 | _('SCIM ID'), 106 | max_length=254, 107 | null=True, 108 | blank=True, 109 | default=None, 110 | unique=True, 111 | help_text=_('A unique identifier for a SCIM resource as defined by the service provider.'), 112 | ) 113 | 114 | """ 115 | externalId 116 | A String that is an identifier for the resource as defined by the 117 | provisioning client. The "externalId" may simplify identification 118 | of a resource between the provisioning client and the service 119 | provider by allowing the client to use a filter to locate the 120 | resource with an identifier from the provisioning domain, 121 | obviating the need to store a local mapping between the 122 | provisioning domain's identifier of the resource and the 123 | identifier used by the service provider. Each resource MAY 124 | include a non-empty "externalId" value. The value of the 125 | "externalId" attribute is always issued by the provisioning client 126 | and MUST NOT be specified by the service provider. The service 127 | provider MUST always interpret the externalId as scoped to the 128 | provisioning domain. While the server does not enforce 129 | uniqueness, it is assumed that the value's uniqueness is 130 | controlled by the client setting the value. See Section 9 for 131 | additional considerations regarding privacy. This attribute has 132 | "caseExact" as "true" and a mutability of "readWrite". This 133 | attribute is OPTIONAL. 134 | """ 135 | scim_external_id = models.CharField( 136 | _('SCIM External ID'), 137 | max_length=254, 138 | null=True, 139 | blank=True, 140 | default=None, 141 | db_index=True, 142 | help_text=_('A string that is an identifier for the resource as defined by the provisioning client.'), 143 | ) 144 | 145 | def set_scim_id(self, is_new): 146 | if is_new: 147 | self.__class__.objects.filter(id=self.id).update(scim_id=self.id) 148 | self.scim_id = str(self.id) 149 | 150 | def save(self, *args, **kwargs): 151 | is_new = self.id is None 152 | super(AbstractSCIMCommonAttributesMixin, self).save(*args, **kwargs) 153 | self.set_scim_id(is_new) 154 | 155 | class Meta: 156 | abstract = True 157 | 158 | 159 | class AbstractSCIMUserMixin(AbstractSCIMCommonAttributesMixin): 160 | """ 161 | An abstract model to provide the User resource schema. 162 | 163 | # https://tools.ietf.org/html/rfc7643#section-4.1 164 | """ 165 | 166 | """ 167 | userName 168 | A service provider's unique identifier for the user, typically 169 | used by the user to directly authenticate to the service provider. 170 | Often displayed to the user as their unique identifier within the 171 | system (as opposed to "id" or "externalId", which are generally 172 | opaque and not user-friendly identifiers). Each User MUST include 173 | a non-empty userName value. This identifier MUST be unique across 174 | the service provider's entire set of Users. This attribute is 175 | REQUIRED and is case insensitive. 176 | """ 177 | scim_username = models.CharField( 178 | _('SCIM Username'), 179 | max_length=254, 180 | null=True, 181 | blank=True, 182 | default=None, 183 | db_index=True, 184 | help_text=_("A service provider's unique identifier for the user"), 185 | ) 186 | 187 | @property 188 | def scim_groups(self): 189 | raise exceptions.NotImplementedError 190 | 191 | class Meta: 192 | abstract = True 193 | 194 | 195 | class AbstractSCIMGroupMixin(AbstractSCIMCommonAttributesMixin): 196 | """ 197 | An abstract model to provide the Group resource schema. 198 | 199 | # https://tools.ietf.org/html/rfc7643#section-4.2 200 | """ 201 | 202 | """ 203 | displayName 204 | A human-readable name for the Group. REQUIRED. 205 | """ 206 | scim_display_name = models.CharField( 207 | _('SCIM Display Name'), 208 | max_length=254, 209 | null=True, 210 | blank=True, 211 | default=None, 212 | db_index=True, 213 | help_text=_("A human-readable name for the Group."), 214 | ) 215 | 216 | class Meta: 217 | abstract = True 218 | 219 | def set_scim_display_name(self, is_new): 220 | if is_new: 221 | self.__class__.objects.filter(id=self.id).update(scim_display_name=self.name) 222 | self.scim_display_name = self.name 223 | 224 | def save(self, *args, **kwargs): 225 | is_new = self.id is None 226 | super(AbstractSCIMGroupMixin, self).save(*args, **kwargs) 227 | self.set_scim_display_name(is_new) 228 | -------------------------------------------------------------------------------- /src/django_scim/schemas/README.rst: -------------------------------------------------------------------------------- 1 | This directory contains representations of schemas for a number of resources, 2 | including but not limited to: 3 | 4 | - urn:ietf:params:scim:schemas:core:2.0:User 5 | - urn:ietf:params:scim:schemas:core:2.0:Group 6 | - urn:ietf:params:scim:schemas:extension:enterprise:2.0:User 7 | - urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig 8 | - urn:ietf:params:scim:schemas:core:2.0:ResourceType 9 | - urn:ietf:params:scim:schemas:core:2.0:Schema 10 | 11 | 12 | Copied from: https://tools.ietf.org/html/rfc7643#section-8.7 13 | 14 | -------------------------------------------------------------------------------- /src/django_scim/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | SCHEMAS_DIR = os.path.dirname(os.path.abspath(__file__)) 5 | SCHEMA_SUB_DIRS = ('core', 'extension') 6 | 7 | 8 | def load_schemas(): 9 | schemas = [] 10 | for dir_ in SCHEMA_SUB_DIRS: 11 | sub_dir = os.path.join(SCHEMAS_DIR, dir_) 12 | files = os.listdir(sub_dir) 13 | files = [os.path.join(sub_dir, f) for f in files if f.lower().endswith('.json')] 14 | for file_ in files: 15 | with open(file_) as fp: 16 | schemas.append(json.load(fp)) 17 | 18 | return schemas 19 | 20 | 21 | ALL = load_schemas() 22 | 23 | 24 | def default_schemas_getter(): 25 | return ALL 26 | -------------------------------------------------------------------------------- /src/django_scim/schemas/core/Group.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "urn:ietf:params:scim:schemas:core:2.0:Group", 3 | "name": "Group", 4 | "description": "Group", 5 | "attributes": [ 6 | { 7 | "name": "displayName", 8 | "type": "string", 9 | "multiValued": false, 10 | "description": "A human-readable name for the Group. REQUIRED.", 11 | "required": false, 12 | "caseExact": false, 13 | "mutability": "readWrite", 14 | "returned": "default", 15 | "uniqueness": "none" 16 | }, 17 | { 18 | "name": "members", 19 | "type": "complex", 20 | "multiValued": true, 21 | "description": "A list of members of the Group.", 22 | "required": false, 23 | "subAttributes": [ 24 | { 25 | "name": "value", 26 | "type": "string", 27 | "multiValued": false, 28 | "description": "Identifier of the member of this Group.", 29 | "required": false, 30 | "caseExact": false, 31 | "mutability": "immutable", 32 | "returned": "default", 33 | "uniqueness": "none" 34 | }, 35 | { 36 | "name": "$ref", 37 | "type": "reference", 38 | "referenceTypes": [ 39 | "User", 40 | "Group" 41 | ], 42 | "multiValued": false, 43 | "description": "The URI corresponding to a SCIM resource that is a member of this Group.", 44 | "required": false, 45 | "caseExact": false, 46 | "mutability": "immutable", 47 | "returned": "default", 48 | "uniqueness": "none" 49 | }, 50 | { 51 | "name": "type", 52 | "type": "string", 53 | "multiValued": false, 54 | "description": "A label indicating the type of resource, e.g., 'User' or 'Group'.", 55 | "required": false, 56 | "caseExact": false, 57 | "canonicalValues": [ 58 | "User", 59 | "Group" 60 | ], 61 | "mutability": "immutable", 62 | "returned": "default", 63 | "uniqueness": "none" 64 | } 65 | ], 66 | "mutability": "readWrite", 67 | "returned": "default" 68 | } 69 | ], 70 | "meta": { 71 | "resourceType": "Schema", 72 | "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/django_scim/schemas/core/ResourceType.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", 3 | "name": "ResourceType", 4 | "description": "Specifies the schema that describes a SCIM resource type", 5 | "attributes": [ 6 | { 7 | "name": "id", 8 | "type": "string", 9 | "multiValued": false, 10 | "description": "The resource type's server unique id. May be the same as the 'name' attribute.", 11 | "required": false, 12 | "caseExact": false, 13 | "mutability": "readOnly", 14 | "returned": "default", 15 | "uniqueness": "none" 16 | }, 17 | { 18 | "name": "name", 19 | "type": "string", 20 | "multiValued": false, 21 | "description": "The resource type name. When applicable, service providers MUST specify the name, e.g., 'User'.", 22 | "required": true, 23 | "caseExact": false, 24 | "mutability": "readOnly", 25 | "returned": "default", 26 | "uniqueness": "none" 27 | }, 28 | { 29 | "name": "description", 30 | "type": "string", 31 | "multiValued": false, 32 | "description": "The resource type's human-readable description. When applicable, service providers MUST specify the description.", 33 | "required": false, 34 | "caseExact": false, 35 | "mutability": "readOnly", 36 | "returned": "default", 37 | "uniqueness": "none" 38 | }, 39 | { 40 | "name": "endpoint", 41 | "type": "reference", 42 | "referenceTypes": [ 43 | "uri" 44 | ], 45 | "multiValued": false, 46 | "description": "The resource type's HTTP-addressable endpoint relative to the Base URL, e.g., '\/Users'.", 47 | "required": true, 48 | "caseExact": false, 49 | "mutability": "readOnly", 50 | "returned": "default", 51 | "uniqueness": "none" 52 | }, 53 | { 54 | "name": "schema", 55 | "type": "reference", 56 | "referenceTypes": [ 57 | "uri" 58 | ], 59 | "multiValued": false, 60 | "description": "The resource type's primary\/base schema URI.", 61 | "required": true, 62 | "caseExact": true, 63 | "mutability": "readOnly", 64 | "returned": "default", 65 | "uniqueness": "none" 66 | }, 67 | { 68 | "name": "schemaExtensions", 69 | "type": "complex", 70 | "multiValued": false, 71 | "description": "A list of URIs of the resource type's schema extensions.", 72 | "required": true, 73 | "mutability": "readOnly", 74 | "returned": "default", 75 | "subAttributes": [ 76 | { 77 | "name": "schema", 78 | "type": "reference", 79 | "referenceTypes": [ 80 | "uri" 81 | ], 82 | "multiValued": false, 83 | "description": "The URI of a schema extension.", 84 | "required": true, 85 | "caseExact": true, 86 | "mutability": "readOnly", 87 | "returned": "default", 88 | "uniqueness": "none" 89 | }, 90 | { 91 | "name": "required", 92 | "type": "boolean", 93 | "multiValued": false, 94 | "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.", 95 | "required": true, 96 | "mutability": "readOnly", 97 | "returned": "default" 98 | } 99 | ] 100 | } 101 | ] 102 | } 103 | -------------------------------------------------------------------------------- /src/django_scim/schemas/core/Schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", 3 | "name": "Schema", 4 | "description": "Specifies the schema that describes a SCIM schema", 5 | "attributes": [ 6 | { 7 | "name": "id", 8 | "type": "string", 9 | "multiValued": false, 10 | "description": "The unique URI of the schema. When applicable, service providers MUST specify the URI.", 11 | "required": true, 12 | "caseExact": false, 13 | "mutability": "readOnly", 14 | "returned": "default", 15 | "uniqueness": "none" 16 | }, 17 | { 18 | "name": "name", 19 | "type": "string", 20 | "multiValued": false, 21 | "description": "The schema's human-readable name. When applicable, service providers MUST specify the name, e.g., 'User'.", 22 | "required": true, 23 | "caseExact": false, 24 | "mutability": "readOnly", 25 | "returned": "default", 26 | "uniqueness": "none" 27 | }, 28 | { 29 | "name": "description", 30 | "type": "string", 31 | "multiValued": false, 32 | "description": "The schema's human-readable name. When applicable, service providers MUST specify the name, e.g., 'User'.", 33 | "required": false, 34 | "caseExact": false, 35 | "mutability": "readOnly", 36 | "returned": "default", 37 | "uniqueness": "none" 38 | }, 39 | { 40 | "name": "attributes", 41 | "type": "complex", 42 | "multiValued": true, 43 | "description": "A complex attribute that includes the attributes of a schema.", 44 | "required": true, 45 | "mutability": "readOnly", 46 | "returned": "default", 47 | "subAttributes": [ 48 | { 49 | "name": "name", 50 | "type": "string", 51 | "multiValued": false, 52 | "description": "The attribute's name.", 53 | "required": true, 54 | "caseExact": true, 55 | "mutability": "readOnly", 56 | "returned": "default", 57 | "uniqueness": "none" 58 | }, 59 | { 60 | "name": "type", 61 | "type": "string", 62 | "multiValued": false, 63 | "description": "The attribute's data type. Valid values include 'string', 'complex', 'boolean', 'decimal', 'integer', 'dateTime', 'reference'.", 64 | "required": true, 65 | "canonicalValues": [ 66 | "string", 67 | "complex", 68 | "boolean", 69 | "decimal", 70 | "integer", 71 | "dateTime", 72 | "reference" 73 | ], 74 | "caseExact": false, 75 | "mutability": "readOnly", 76 | "returned": "default", 77 | "uniqueness": "none" 78 | }, 79 | { 80 | "name": "multiValued", 81 | "type": "boolean", 82 | "multiValued": false, 83 | "description": "A Boolean value indicating an attribute's plurality.", 84 | "required": true, 85 | "mutability": "readOnly", 86 | "returned": "default" 87 | }, 88 | { 89 | "name": "description", 90 | "type": "string", 91 | "multiValued": false, 92 | "description": "A human-readable description of the attribute.", 93 | "required": false, 94 | "caseExact": true, 95 | "mutability": "readOnly", 96 | "returned": "default", 97 | "uniqueness": "none" 98 | }, 99 | { 100 | "name": "required", 101 | "type": "boolean", 102 | "multiValued": false, 103 | "description": "A boolean value indicating whether or not the attribute is required.", 104 | "required": false, 105 | "mutability": "readOnly", 106 | "returned": "default" 107 | }, 108 | { 109 | "name": "canonicalValues", 110 | "type": "string", 111 | "multiValued": true, 112 | "description": "A collection of canonical values. When applicable, service providers MUST specify the canonical types, e.g., 'work', 'home'.", 113 | "required": false, 114 | "caseExact": true, 115 | "mutability": "readOnly", 116 | "returned": "default", 117 | "uniqueness": "none" 118 | }, 119 | { 120 | "name": "caseExact", 121 | "type": "boolean", 122 | "multiValued": false, 123 | "description": "A Boolean value indicating whether or not a string attribute is case sensitive.", 124 | "required": false, 125 | "mutability": "readOnly", 126 | "returned": "default" 127 | }, 128 | { 129 | "name": "mutability", 130 | "type": "string", 131 | "multiValued": false, 132 | "description": "Indicates whether or not an attribute is modifiable.", 133 | "required": false, 134 | "caseExact": true, 135 | "mutability": "readOnly", 136 | "returned": "default", 137 | "uniqueness": "none", 138 | "canonicalValues": [ 139 | "readOnly", 140 | "readWrite", 141 | "immutable", 142 | "writeOnly" 143 | ] 144 | }, 145 | { 146 | "name": "returned", 147 | "type": "string", 148 | "multiValued": false, 149 | "description": "Indicates when an attribute is returned in a response (e.g., to a query).", 150 | "required": false, 151 | "caseExact": true, 152 | "mutability": "readOnly", 153 | "returned": "default", 154 | "uniqueness": "none", 155 | "canonicalValues": [ 156 | "always", 157 | "never", 158 | "default", 159 | "request" 160 | ] 161 | }, 162 | { 163 | "name": "uniqueness", 164 | "type": "string", 165 | "multiValued": false, 166 | "description": "Indicates how unique a value must be.", 167 | "required": false, 168 | "caseExact": true, 169 | "mutability": "readOnly", 170 | "returned": "default", 171 | "uniqueness": "none", 172 | "canonicalValues": [ 173 | "none", 174 | "server", 175 | "global" 176 | ] 177 | }, 178 | { 179 | "name": "referenceTypes", 180 | "type": "string", 181 | "multiValued": true, 182 | "description": "Used only with an attribute of type 'reference'. Specifies a SCIM resourceType that a reference attribute MAY refer to, e.g., 'User'.", 183 | "required": false, 184 | "caseExact": true, 185 | "mutability": "readOnly", 186 | "returned": "default", 187 | "uniqueness": "none" 188 | }, 189 | { 190 | "name": "subAttributes", 191 | "type": "complex", 192 | "multiValued": true, 193 | "description": "Used to define the sub-attributes of a complex attribute.", 194 | "required": false, 195 | "mutability": "readOnly", 196 | "returned": "default", 197 | "subAttributes": [ 198 | { 199 | "name": "name", 200 | "type": "string", 201 | "multiValued": false, 202 | "description": "The attribute's name.", 203 | "required": true, 204 | "caseExact": true, 205 | "mutability": "readOnly", 206 | "returned": "default", 207 | "uniqueness": "none" 208 | }, 209 | { 210 | "name": "type", 211 | "type": "string", 212 | "multiValued": false, 213 | "description": "The attribute's data type. Valid values include 'string', 'complex', 'boolean', 'decimal', 'integer', 'dateTime', 'reference'.", 214 | "required": true, 215 | "caseExact": false, 216 | "mutability": "readOnly", 217 | "returned": "default", 218 | "uniqueness": "none", 219 | "canonicalValues": [ 220 | "string", 221 | "complex", 222 | "boolean", 223 | "decimal", 224 | "integer", 225 | "dateTime", 226 | "reference" 227 | ] 228 | }, 229 | { 230 | "name": "multiValued", 231 | "type": "boolean", 232 | "multiValued": false, 233 | "description": "A Boolean value indicating an attribute's plurality.", 234 | "required": true, 235 | "mutability": "readOnly", 236 | "returned": "default" 237 | }, 238 | { 239 | "name": "description", 240 | "type": "string", 241 | "multiValued": false, 242 | "description": "A human-readable description of the attribute.", 243 | "required": false, 244 | "caseExact": true, 245 | "mutability": "readOnly", 246 | "returned": "default", 247 | "uniqueness": "none" 248 | }, 249 | { 250 | "name": "required", 251 | "type": "boolean", 252 | "multiValued": false, 253 | "description": "A boolean value indicating whether or not the attribute is required.", 254 | "required": false, 255 | "mutability": "readOnly", 256 | "returned": "default" 257 | }, 258 | { 259 | "name": "canonicalValues", 260 | "type": "string", 261 | "multiValued": true, 262 | "description": "A collection of canonical values. When applicable, service providers MUST specify the canonical types, e.g., 'work', 'home'.", 263 | "required": false, 264 | "caseExact": true, 265 | "mutability": "readOnly", 266 | "returned": "default", 267 | "uniqueness": "none" 268 | }, 269 | { 270 | "name": "caseExact", 271 | "type": "boolean", 272 | "multiValued": false, 273 | "description": "A Boolean value indicating whether or not a string attribute is case sensitive.", 274 | "required": false, 275 | "mutability": "readOnly", 276 | "returned": "default" 277 | }, 278 | { 279 | "name": "mutability", 280 | "type": "string", 281 | "multiValued": false, 282 | "description": "Indicates whether or not an attribute is modifiable.", 283 | "required": false, 284 | "caseExact": true, 285 | "mutability": "readOnly", 286 | "returned": "default", 287 | "uniqueness": "none", 288 | "canonicalValues": [ 289 | "readOnly", 290 | "readWrite", 291 | "immutable", 292 | "writeOnly" 293 | ] 294 | }, 295 | { 296 | "name": "returned", 297 | "type": "string", 298 | "multiValued": false, 299 | "description": "Indicates when an attribute is returned in a response (e.g., to a query).", 300 | "required": false, 301 | "caseExact": true, 302 | "mutability": "readOnly", 303 | "returned": "default", 304 | "uniqueness": "none", 305 | "canonicalValues": [ 306 | "always", 307 | "never", 308 | "default", 309 | "request" 310 | ] 311 | }, 312 | { 313 | "name": "uniqueness", 314 | "type": "string", 315 | "multiValued": false, 316 | "description": "Indicates how unique a value must be.", 317 | "required": false, 318 | "caseExact": true, 319 | "mutability": "readOnly", 320 | "returned": "default", 321 | "uniqueness": "none", 322 | "canonicalValues": [ 323 | "none", 324 | "server", 325 | "global" 326 | ] 327 | }, 328 | { 329 | "name": "referenceTypes", 330 | "type": "string", 331 | "multiValued": false, 332 | "description": "Used only with an attribute of type 'reference'. Specifies a SCIM resourceType that a reference attribute MAY refer to, e.g., 'User'.", 333 | "required": false, 334 | "caseExact": true, 335 | "mutability": "readOnly", 336 | "returned": "default", 337 | "uniqueness": "none" 338 | } 339 | ] 340 | } 341 | ] 342 | } 343 | ] 344 | } 345 | -------------------------------------------------------------------------------- /src/django_scim/schemas/core/ServiceProviderConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", 3 | "name": "Service Provider Configuration", 4 | "description": "Schema for representing the service provider's configuration", 5 | "attributes": [ 6 | { 7 | "name": "documentationUri", 8 | "type": "reference", 9 | "referenceTypes": [ 10 | "external" 11 | ], 12 | "multiValued": false, 13 | "description": "An HTTP-addressable URL pointing to the service provider's human-consumable help documentation.", 14 | "required": false, 15 | "caseExact": false, 16 | "mutability": "readOnly", 17 | "returned": "default", 18 | "uniqueness": "none" 19 | }, 20 | { 21 | "name": "patch", 22 | "type": "complex", 23 | "multiValued": false, 24 | "description": "A complex type that specifies PATCH configuration options.", 25 | "required": true, 26 | "returned": "default", 27 | "mutability": "readOnly", 28 | "subAttributes": [ 29 | { 30 | "name": "supported", 31 | "type": "boolean", 32 | "multiValued": false, 33 | "description": "A Boolean value specifying whether or not the operation is supported.", 34 | "required": true, 35 | "mutability": "readOnly", 36 | "returned": "default" 37 | } 38 | ] 39 | }, 40 | { 41 | "name": "bulk", 42 | "type": "complex", 43 | "multiValued": false, 44 | "description": "A complex type that specifies bulk configuration options.", 45 | "required": true, 46 | "returned": "default", 47 | "mutability": "readOnly", 48 | "subAttributes": [ 49 | { 50 | "name": "supported", 51 | "type": "boolean", 52 | "multiValued": false, 53 | "description": "A Boolean value specifying whether or not the operation is supported.", 54 | "required": true, 55 | "mutability": "readOnly", 56 | "returned": "default" 57 | }, 58 | { 59 | "name": "maxOperations", 60 | "type": "integer", 61 | "multiValued": false, 62 | "description": "An integer value specifying the maximum number of operations.", 63 | "required": true, 64 | "mutability": "readOnly", 65 | "returned": "default", 66 | "uniqueness": "none" 67 | }, 68 | { 69 | "name": "maxPayloadSize", 70 | "type": "integer", 71 | "multiValued": false, 72 | "description": "An integer value specifying the maximum payload size in bytes.", 73 | "required": true, 74 | "mutability": "readOnly", 75 | "returned": "default", 76 | "uniqueness": "none" 77 | } 78 | ] 79 | }, 80 | { 81 | "name": "filter", 82 | "type": "complex", 83 | "multiValued": false, 84 | "description": "A complex type that specifies FILTER options.", 85 | "required": true, 86 | "returned": "default", 87 | "mutability": "readOnly", 88 | "subAttributes": [ 89 | { 90 | "name": "supported", 91 | "type": "boolean", 92 | "multiValued": false, 93 | "description": "A Boolean value specifying whether or not the operation is supported.", 94 | "required": true, 95 | "mutability": "readOnly", 96 | "returned": "default" 97 | }, 98 | { 99 | "name": "maxResults", 100 | "type": "integer", 101 | "multiValued": false, 102 | "description": "An integer value specifying the maximum number of resources returned in a response.", 103 | "required": true, 104 | "mutability": "readOnly", 105 | "returned": "default", 106 | "uniqueness": "none" 107 | } 108 | ] 109 | }, 110 | { 111 | "name": "changePassword", 112 | "type": "complex", 113 | "multiValued": false, 114 | "description": "A complex type that specifies configuration options related to changing a password.", 115 | "required": true, 116 | "returned": "default", 117 | "mutability": "readOnly", 118 | "subAttributes": [ 119 | { 120 | "name": "supported", 121 | "type": "boolean", 122 | "multiValued": false, 123 | "description": "A Boolean value specifying whether or not the operation is supported.", 124 | "required": true, 125 | "mutability": "readOnly", 126 | "returned": "default" 127 | } 128 | ] 129 | }, 130 | { 131 | "name": "sort", 132 | "type": "complex", 133 | "multiValued": false, 134 | "description": "A complex type that specifies sort result options.", 135 | "required": true, 136 | "returned": "default", 137 | "mutability": "readOnly", 138 | "subAttributes": [ 139 | { 140 | "name": "supported", 141 | "type": "boolean", 142 | "multiValued": false, 143 | "description": "A Boolean value specifying whether or not the operation is supported.", 144 | "required": true, 145 | "mutability": "readOnly", 146 | "returned": "default" 147 | } 148 | ] 149 | }, 150 | { 151 | "name": "authenticationSchemes", 152 | "type": "complex", 153 | "multiValued": true, 154 | "description": "A complex type that specifies supported authentication scheme properties.", 155 | "required": true, 156 | "returned": "default", 157 | "mutability": "readOnly", 158 | "subAttributes": [ 159 | { 160 | "name": "name", 161 | "type": "string", 162 | "multiValued": false, 163 | "description": "The common authentication scheme name, e.g., HTTP Basic.", 164 | "required": true, 165 | "caseExact": false, 166 | "mutability": "readOnly", 167 | "returned": "default", 168 | "uniqueness": "none" 169 | }, 170 | { 171 | "name": "description", 172 | "type": "string", 173 | "multiValued": false, 174 | "description": "A description of the authentication scheme.", 175 | "required": true, 176 | "caseExact": false, 177 | "mutability": "readOnly", 178 | "returned": "default", 179 | "uniqueness": "none" 180 | }, 181 | { 182 | "name": "specUri", 183 | "type": "reference", 184 | "referenceTypes": [ 185 | "external" 186 | ], 187 | "multiValued": false, 188 | "description": "An HTTP-addressable URL pointing to the authentication scheme's specification.", 189 | "required": false, 190 | "caseExact": false, 191 | "mutability": "readOnly", 192 | "returned": "default", 193 | "uniqueness": "none" 194 | }, 195 | { 196 | "name": "documentationUri", 197 | "type": "reference", 198 | "referenceTypes": [ 199 | "external" 200 | ], 201 | "multiValued": false, 202 | "description": "An HTTP-addressable URL pointing to the authentication scheme's usage documentation.", 203 | "required": false, 204 | "caseExact": false, 205 | "mutability": "readOnly", 206 | "returned": "default", 207 | "uniqueness": "none" 208 | } 209 | ] 210 | } 211 | ] 212 | } 213 | -------------------------------------------------------------------------------- /src/django_scim/schemas/extension/Enterprise-User.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", 3 | "name": "EnterpriseUser", 4 | "description": "Enterprise User", 5 | "attributes": [ 6 | { 7 | "name": "employeeNumber", 8 | "type": "string", 9 | "multiValued": false, 10 | "description": "Numeric or alphanumeric identifier assigned to a person, typically based on order of hire or association with an organization.", 11 | "required": false, 12 | "caseExact": false, 13 | "mutability": "readWrite", 14 | "returned": "default", 15 | "uniqueness": "none" 16 | }, 17 | { 18 | "name": "costCenter", 19 | "type": "string", 20 | "multiValued": false, 21 | "description": "Identifies the name of a cost center.", 22 | "required": false, 23 | "caseExact": false, 24 | "mutability": "readWrite", 25 | "returned": "default", 26 | "uniqueness": "none" 27 | }, 28 | { 29 | "name": "organization", 30 | "type": "string", 31 | "multiValued": false, 32 | "description": "Identifies the name of an organization.", 33 | "required": false, 34 | "caseExact": false, 35 | "mutability": "readWrite", 36 | "returned": "default", 37 | "uniqueness": "none" 38 | }, 39 | { 40 | "name": "division", 41 | "type": "string", 42 | "multiValued": false, 43 | "description": "Identifies the name of a division.", 44 | "required": false, 45 | "caseExact": false, 46 | "mutability": "readWrite", 47 | "returned": "default", 48 | "uniqueness": "none" 49 | }, 50 | { 51 | "name": "department", 52 | "type": "string", 53 | "multiValued": false, 54 | "description": "Identifies the name of a department.", 55 | "required": false, 56 | "caseExact": false, 57 | "mutability": "readWrite", 58 | "returned": "default", 59 | "uniqueness": "none" 60 | }, 61 | { 62 | "name": "manager", 63 | "type": "complex", 64 | "multiValued": false, 65 | "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.", 66 | "required": false, 67 | "subAttributes": [ 68 | { 69 | "name": "value", 70 | "type": "string", 71 | "multiValued": false, 72 | "description": "The id of the SCIM resource representing the User's manager. REQUIRED.", 73 | "required": false, 74 | "caseExact": false, 75 | "mutability": "readWrite", 76 | "returned": "default", 77 | "uniqueness": "none" 78 | }, 79 | { 80 | "name": "$ref", 81 | "type": "reference", 82 | "referenceTypes": [ 83 | "User" 84 | ], 85 | "multiValued": false, 86 | "description": "The URI of the SCIM resource representing the User's manager. REQUIRED.", 87 | "required": false, 88 | "caseExact": false, 89 | "mutability": "readWrite", 90 | "returned": "default", 91 | "uniqueness": "none" 92 | }, 93 | { 94 | "name": "displayName", 95 | "type": "string", 96 | "multiValued": false, 97 | "description": "The displayName of the User's manager. OPTIONAL and READ-ONLY.", 98 | "required": false, 99 | "caseExact": false, 100 | "mutability": "readOnly", 101 | "returned": "default", 102 | "uniqueness": "none" 103 | } 104 | ], 105 | "mutability": "readWrite", 106 | "returned": "default" 107 | } 108 | ], 109 | "meta": { 110 | "resourceType": "Schema", 111 | "location": "/v2/Schemas/urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/django_scim/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is largely inspired by django-rest-framework settings. 3 | 4 | Settings for the SCIM Service Provider are all namespaced in the 5 | SCIM_SERVICE_PROVIDER setting. For example your project's `settings.py` 6 | file might look like this: 7 | 8 | SCIM_SERVICE_PROVIDER = { 9 | 'USER_ADAPTER': 'django_scim.adapters.SCIMUser', 10 | 'GROUP_ADAPTER': 'django_scim.adapters.SCIMGroup', 11 | } 12 | 13 | This module provides the `scim_settings` object, that is used to access 14 | SCIM Service Provider settings, checking for user settings first, then falling 15 | back to the defaults. 16 | """ 17 | import importlib 18 | 19 | from django.conf import settings 20 | 21 | # Settings defined by user in root settings file for their project. 22 | USER_SETTINGS = getattr(settings, 'SCIM_SERVICE_PROVIDER', None) 23 | 24 | DEFAULTS = { 25 | 'USER_MODEL_GETTER': 'django.contrib.auth.get_user_model', 26 | 'USER_ADAPTER': 'django_scim.adapters.SCIMUser', 27 | 'GROUP_MODEL': 'django.contrib.auth.models.Group', 28 | 'GROUP_ADAPTER': 'django_scim.adapters.SCIMGroup', 29 | 'USER_FILTER_PARSER': 'django_scim.filters.UserFilterQuery', 30 | 'GROUP_FILTER_PARSER': 'django_scim.filters.GroupFilterQuery', 31 | 'SERVICE_PROVIDER_CONFIG_MODEL': 'django_scim.models.SCIMServiceProviderConfig', 32 | 'BASE_LOCATION_GETTER': 'django_scim.utils.default_base_scim_location_getter', 33 | 'GET_EXTRA_MODEL_FILTER_KWARGS_GETTER': 'django_scim.utils.default_get_extra_model_filter_kwargs_getter', 34 | 'GET_EXTRA_MODEL_EXCLUDE_KWARGS_GETTER': 'django_scim.utils.default_get_extra_model_exclude_kwargs_getter', 35 | 'GET_OBJECT_POST_PROCESSOR_GETTER': 'django_scim.utils.default_get_object_post_processor_getter', 36 | 'GET_QUERYSET_POST_PROCESSOR_GETTER': 'django_scim.utils.default_get_queryset_post_processor_getter', 37 | 'GET_IS_AUTHENTICATED_PREDICATE': 'django_scim.utils.default_is_authenticated_predicate', 38 | 'AUTH_CHECK_MIDDLEWARE': 'django_scim.middleware.SCIMAuthCheckMiddleware', 39 | 'SCHEMAS_GETTER': 'django_scim.schemas.default_schemas_getter', 40 | 'DOCUMENTATION_URI': None, 41 | 'SCHEME': 'https', 42 | 'NETLOC': None, 43 | 'EXPOSE_SCIM_EXCEPTIONS': False, 44 | 'AUTHENTICATION_SCHEMES': [], 45 | 'WWW_AUTHENTICATE_HEADER': 'Basic realm="django-scim2"', 46 | } 47 | 48 | # List of settings that cannot be empty 49 | MANDATORY = ( 50 | 'NETLOC', 51 | 'AUTHENTICATION_SCHEMES', 52 | ) 53 | 54 | # List of settings that may be in string import notation. 55 | IMPORT_STRINGS = ( 56 | 'USER_MODEL_GETTER', 57 | 'USER_ADAPTER', 58 | 'GROUP_MODEL', 59 | 'GROUP_ADAPTER', 60 | 'USER_FILTER_PARSER', 61 | 'GROUP_FILTER_PARSER', 62 | 'SERVICE_PROVIDER_CONFIG_MODEL', 63 | 'BASE_LOCATION_GETTER', 64 | 'GET_EXTRA_MODEL_FILTER_KWARGS_GETTER', 65 | 'GET_EXTRA_MODEL_EXCLUDE_KWARGS_GETTER', 66 | 'GET_OBJECT_POST_PROCESSOR_GETTER', 67 | 'GET_QUERYSET_POST_PROCESSOR_GETTER', 68 | 'GET_IS_AUTHENTICATED_PREDICATE', 69 | 'AUTH_CHECK_MIDDLEWARE', 70 | 'SCHEMAS_GETTER', 71 | ) 72 | 73 | 74 | def perform_import(val, setting_name): 75 | """ 76 | If the given setting is a string import notation, 77 | then perform the necessary import or imports. 78 | """ 79 | if isinstance(val, str): 80 | return import_from_string(val, setting_name) 81 | elif isinstance(val, (list, tuple)): 82 | return [import_from_string(item, setting_name) for item in val] 83 | return val 84 | 85 | 86 | def import_from_string(val, setting_name): 87 | """ 88 | Attempt to import a class from a string representation. 89 | """ 90 | try: 91 | parts = val.split('.') 92 | module_path, class_name = '.'.join(parts[:-1]), parts[-1] 93 | module = importlib.import_module(module_path) 94 | return getattr(module, class_name) 95 | except ImportError as e: 96 | msg = "Could not import '%s' for setting '%s'. %s: %s." % (val, setting_name, e.__class__.__name__, e) 97 | raise ImportError(msg) 98 | 99 | 100 | class SCIMServiceProviderSettings(object): 101 | """ 102 | A settings object, that allows SCIM Service Provider settings to be accessed as properties. 103 | 104 | Any setting with string import paths will be automatically resolved 105 | and return the class, rather than the string literal. 106 | """ 107 | 108 | def __init__(self, user_settings=None, defaults=None, import_strings=None, mandatory=None): 109 | self.user_settings = user_settings or {} 110 | self.defaults = defaults or {} 111 | self.import_strings = import_strings or () 112 | self.mandatory = mandatory or () 113 | 114 | def __getattr__(self, attr): 115 | if attr not in self.defaults.keys(): 116 | raise AttributeError("Invalid SCIMServiceProvider setting: '%s'" % attr) 117 | 118 | try: 119 | # Check if present in user settings 120 | val = self.user_settings[attr] 121 | except KeyError: 122 | # Fall back to defaults 123 | val = self.defaults[attr] 124 | 125 | # Coerce import strings into classes 126 | if val and attr in self.import_strings: 127 | val = perform_import(val, attr) 128 | 129 | self.validate_setting(attr, val) 130 | 131 | # Cache the result 132 | setattr(self, attr, val) 133 | return val 134 | 135 | def validate_setting(self, attr, val): 136 | if not val and attr in self.mandatory: 137 | raise AttributeError("SCIMServiceProvider setting: '%s' is mandatory" % attr) 138 | 139 | 140 | scim_settings = SCIMServiceProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) 141 | -------------------------------------------------------------------------------- /src/django_scim/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import re_path 3 | except ImportError: 4 | from django.conf.urls import url as re_path 5 | 6 | from . import views 7 | 8 | app_name = 'scim' 9 | 10 | urlpatterns = [ 11 | # This endpoint is used soley for middleware url purposes. 12 | re_path(r'^$', 13 | views.SCIMView.as_view(implemented=False), 14 | name='root'), 15 | 16 | re_path(r'^\.search$', 17 | views.SearchView.as_view(implemented=False), 18 | name='search'), 19 | 20 | re_path(r'^Users/\.search$', 21 | views.UserSearchView.as_view(), 22 | name='users-search'), 23 | 24 | re_path(r'^Users(?:/(?P[^/]+))?$', 25 | views.UsersView.as_view(), 26 | name='users'), 27 | 28 | re_path(r'^Groups/\.search$', 29 | views.GroupSearchView.as_view(), 30 | name='groups-search'), 31 | 32 | re_path(r'^Groups(?:/(?P[^/]+))?$', 33 | views.GroupsView.as_view(), 34 | name='groups'), 35 | 36 | re_path(r'^Me$', 37 | views.SCIMView.as_view(implemented=False), 38 | name='me'), 39 | 40 | re_path(r'^ServiceProviderConfig$', 41 | views.ServiceProviderConfigView.as_view(), 42 | name='service-provider-config'), 43 | 44 | re_path(r'^ResourceTypes(?:/(?P[^/]+))?$', 45 | views.ResourceTypesView.as_view(), 46 | name='resource-types'), 47 | 48 | re_path(r'^Schemas(?:/(?P[^/]+))?$', 49 | views.SchemasView.as_view(), 50 | name='schemas'), 51 | 52 | re_path(r'^Bulk$', 53 | views.SCIMView.as_view(implemented=False), 54 | name='bulk'), 55 | ] 56 | -------------------------------------------------------------------------------- /src/django_scim/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import urlunparse 3 | 4 | from .settings import scim_settings 5 | 6 | 7 | def get_user_model(): 8 | """ 9 | Return the user model. 10 | """ 11 | return scim_settings.USER_MODEL_GETTER() 12 | 13 | 14 | def get_user_adapter(): 15 | """ 16 | Return the user model adapter. 17 | """ 18 | return scim_settings.USER_ADAPTER 19 | 20 | 21 | def get_group_model(): 22 | """ 23 | Return the group model. 24 | """ 25 | return scim_settings.GROUP_MODEL 26 | 27 | 28 | def get_group_adapter(): 29 | """ 30 | Return the group model adapter. 31 | """ 32 | return scim_settings.GROUP_ADAPTER 33 | 34 | 35 | def get_user_filter_parser(): 36 | """ 37 | Return the user filter parser. 38 | """ 39 | return scim_settings.USER_FILTER_PARSER 40 | 41 | 42 | def get_group_filter_parser(): 43 | """ 44 | Return the group filter parser. 45 | """ 46 | return scim_settings.GROUP_FILTER_PARSER 47 | 48 | 49 | def get_service_provider_config_model(): 50 | """ 51 | Return the Service Provider Config model. 52 | """ 53 | return scim_settings.SERVICE_PROVIDER_CONFIG_MODEL 54 | 55 | 56 | def get_base_scim_location_getter(): 57 | """ 58 | Return a function that will, when called, returns the base 59 | location of scim app. 60 | """ 61 | return scim_settings.BASE_LOCATION_GETTER 62 | 63 | 64 | def get_all_schemas_getter(): 65 | """ 66 | Return a function that will, when called, returns the 67 | all schemas getter function. 68 | """ 69 | return scim_settings.SCHEMAS_GETTER 70 | 71 | 72 | def get_extra_model_filter_kwargs_getter(model): 73 | """ 74 | Return a function that will, when called, returns the 75 | get_extra_filter_kwargs function. 76 | """ 77 | return scim_settings.GET_EXTRA_MODEL_FILTER_KWARGS_GETTER(model) 78 | 79 | 80 | def get_extra_model_exclude_kwargs_getter(model): 81 | """ 82 | Return a function that will, when called, returns the 83 | get_extra_exclude_kwargs function. 84 | """ 85 | return scim_settings.GET_EXTRA_MODEL_EXCLUDE_KWARGS_GETTER(model) 86 | 87 | 88 | def get_object_post_processor_getter(model): 89 | """ 90 | Return a function that will, when called, returns the 91 | get_object_post_processor function. 92 | """ 93 | return scim_settings.GET_OBJECT_POST_PROCESSOR_GETTER(model) 94 | 95 | 96 | def get_queryset_post_processor_getter(model): 97 | """ 98 | Return a function that will, when called, returns the 99 | get_queryset_post_processor function. 100 | """ 101 | return scim_settings.GET_QUERYSET_POST_PROCESSOR_GETTER(model) 102 | 103 | 104 | def get_is_authenticated_predicate(): 105 | """ 106 | Return function that will perform customized authn/z actions during 107 | `.dispatch` method processing. This defaults to Django's `user.is_authenticated`. 108 | """ 109 | return scim_settings.GET_IS_AUTHENTICATED_PREDICATE 110 | 111 | 112 | def default_is_authenticated_predicate(user): 113 | return user.is_authenticated 114 | 115 | 116 | def default_base_scim_location_getter(request=None, *args, **kwargs): 117 | """ 118 | Return the default location of the app implementing the SCIM api. 119 | """ 120 | base_scim_location_parts = ( 121 | scim_settings.SCHEME, 122 | scim_settings.NETLOC, 123 | '', # path 124 | '', # params 125 | '', # query 126 | '' # fragment 127 | ) 128 | 129 | base_scim_location = urlunparse(base_scim_location_parts) 130 | 131 | return base_scim_location 132 | 133 | 134 | def default_get_extra_model_filter_kwargs_getter(model): 135 | """ 136 | Return a **method** that will return extra model filter kwargs for the passed in model. 137 | 138 | :param model: 139 | """ 140 | def get_extra_filter_kwargs(request, *args, **kwargs): 141 | """ 142 | Return extra filter kwargs for the given model. 143 | :param request: 144 | :param args: 145 | :param kwargs: 146 | :rtype: dict 147 | """ 148 | return {} 149 | 150 | return get_extra_filter_kwargs 151 | 152 | 153 | def default_get_extra_model_exclude_kwargs_getter(model): 154 | """ 155 | Return a **method** that will return extra model exclude kwargs for the passed in model. 156 | 157 | :param model: 158 | """ 159 | def get_extra_exclude_kwargs(request, *args, **kwargs): 160 | """ 161 | Return extra exclude kwargs for the given model. 162 | :param request: 163 | :param args: 164 | :param kwargs: 165 | :rtype: dict 166 | """ 167 | return {} 168 | 169 | return get_extra_exclude_kwargs 170 | 171 | 172 | def default_get_object_post_processor_getter(model): 173 | """ 174 | Return a **method** that can be used to perform any post processing 175 | on an object about to be returned from SCIMView.get_object(). 176 | 177 | :param model: 178 | """ 179 | def get_object_post_processor(request, obj, *args, **kwargs): 180 | """ 181 | Perform any post processing on object to be returned from SCIMView.get_object(). 182 | 183 | :param request: 184 | :param obj: 185 | :param args: 186 | :param kwargs: 187 | :rtype: Django User 188 | """ 189 | return obj 190 | 191 | return get_object_post_processor 192 | 193 | 194 | def default_get_queryset_post_processor_getter(model): 195 | """ 196 | Return a **method** that can be used to perform any post processing 197 | on a queryset about to be returned from GetView.get_many(). 198 | 199 | :param model: 200 | """ 201 | def get_queryset_post_processor(request, qs, *args, **kwargs): 202 | """ 203 | Perform any post processing on queryset to be returned from GetView.get_many(). 204 | 205 | :param request: 206 | :param qs: 207 | :param args: 208 | :param kwargs: 209 | :rtype: Django Queryset 210 | """ 211 | return qs 212 | 213 | return get_queryset_post_processor 214 | 215 | 216 | def clean_structure_of_passwords(obj): 217 | if isinstance(obj, dict): 218 | new_obj = {} 219 | for key, value in obj.items(): 220 | if 'password' in key.lower(): 221 | new_obj[key] = '*' * len(value) if value else None 222 | else: 223 | new_obj[key] = clean_structure_of_passwords(value) 224 | 225 | return new_obj 226 | 227 | elif isinstance(obj, list): 228 | return [clean_structure_of_passwords(item) for item in obj] 229 | 230 | else: 231 | return obj 232 | 233 | 234 | def get_loggable_body(text): 235 | if not text: 236 | return text 237 | 238 | try: 239 | obj = json.loads(text) 240 | except json.JSONDecodeError: 241 | return text 242 | 243 | obj = clean_structure_of_passwords(obj) 244 | 245 | return json.dumps(obj) 246 | -------------------------------------------------------------------------------- /src/django_scim/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from urllib.parse import urljoin 4 | 5 | from django import db 6 | from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist 7 | from django.db import transaction 8 | from django.http import HttpResponse 9 | from django.urls import reverse 10 | from django.utils.decorators import method_decorator 11 | from django.views.decorators.csrf import csrf_exempt 12 | from django.views.generic import View 13 | from scim2_filter_parser.parser import SCIMParserError 14 | 15 | from . import constants, exceptions 16 | from .settings import scim_settings 17 | from .utils import ( 18 | get_all_schemas_getter, 19 | get_base_scim_location_getter, 20 | get_extra_model_exclude_kwargs_getter, 21 | get_extra_model_filter_kwargs_getter, 22 | get_group_adapter, 23 | get_group_filter_parser, 24 | get_group_model, 25 | get_object_post_processor_getter, 26 | get_queryset_post_processor_getter, 27 | get_service_provider_config_model, 28 | get_user_adapter, 29 | get_user_filter_parser, 30 | get_user_model, 31 | ) 32 | 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | class SCIMView(View): 37 | lookup_url_kwarg = 'uuid' # argument in django URL pattern 38 | implemented = True 39 | 40 | @property 41 | def lookup_field(self): 42 | """Database field, possibly redefined in the adapter""" 43 | return getattr(self.scim_adapter, 'id_field', 'scim_id') 44 | 45 | @property 46 | def model_cls(self): 47 | # pull from __class__ to avoid binding model class getter to 48 | # self instance and passing self to class getter 49 | return self.__class__.model_cls_getter() 50 | 51 | @property 52 | def get_extra_filter_kwargs(self): 53 | return get_extra_model_filter_kwargs_getter(self.model_cls) 54 | 55 | @property 56 | def get_extra_exclude_kwargs(self): 57 | return get_extra_model_exclude_kwargs_getter(self.model_cls) 58 | 59 | @property 60 | def get_object_post_processor(self): 61 | return get_object_post_processor_getter(self.model_cls) 62 | 63 | @property 64 | def get_queryset_post_processor(self): 65 | return get_queryset_post_processor_getter(self.model_cls) 66 | 67 | @property 68 | def scim_adapter(self): 69 | # pull from __class__ to avoid binding adapter class getter to 70 | # self instance and passing self to class getter 71 | return self.__class__.scim_adapter_getter() 72 | 73 | def get_object(self): 74 | """Get object by configurable ID.""" 75 | # Perform the lookup filtering. 76 | lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field 77 | 78 | if lookup_url_kwarg not in self.kwargs: 79 | msg = ( 80 | f'Expected view {self.__class__.__name__} to be called with a URL keyword argument ' 81 | f'named "{lookup_url_kwarg}". Fix your URL conf, or set the `.lookup_field` ' 82 | f'attribute on the view correctly.' 83 | ) 84 | raise exceptions.BadRequestError(msg) 85 | 86 | uuid = self.kwargs[lookup_url_kwarg] 87 | 88 | extra_filter_kwargs = self.get_extra_filter_kwargs(self.request, uuid) 89 | extra_filter_kwargs[self.lookup_field] = uuid 90 | # No use of get_extra_exclude_kwargs here since we are 91 | # searching for a specific single object. 92 | 93 | try: 94 | obj = self.model_cls.objects.get(**extra_filter_kwargs) 95 | return self.get_object_post_processor(self.request, obj) 96 | except ObjectDoesNotExist: 97 | raise exceptions.NotFoundError(uuid) 98 | except MultipleObjectsReturned: 99 | msg = ( 100 | f'Multiple objects returned by lookup of {lookup_url_kwarg} with value {uuid}. ' 101 | f'Make sure {lookup_url_kwarg} identifies a unique instance and try again.' 102 | ) 103 | raise exceptions.BadRequestError(msg) 104 | 105 | @method_decorator(csrf_exempt) 106 | @method_decorator(scim_settings.AUTH_CHECK_MIDDLEWARE) 107 | def dispatch(self, request, *args, **kwargs): 108 | if not self.implemented: 109 | return self.status_501(request, *args, **kwargs) 110 | 111 | try: 112 | return super(SCIMView, self).dispatch(request, *args, **kwargs) 113 | except Exception as e: 114 | if not isinstance(e, exceptions.SCIMException): 115 | logger.exception('Unable to complete SCIM call.') 116 | 117 | # In some circumstances it can be beneficial for the client 118 | # to know what caused an error. However, this can present an 119 | # unacceptable security risk for many companies. This flag 120 | # allows for a generic error message to be returned when such a 121 | # security risk is unacceptable. 122 | if scim_settings.EXPOSE_SCIM_EXCEPTIONS: 123 | e = exceptions.SCIMException(str(e)) 124 | else: 125 | e = exceptions.SCIMException('Exception occurred while processing the SCIM request') 126 | 127 | content = json.dumps(e.to_dict()) 128 | return HttpResponse(content=content, 129 | content_type=constants.SCIM_CONTENT_TYPE, 130 | status=e.status) 131 | 132 | def status_501(self, request, *args, **kwargs): 133 | """ 134 | A service provider that does NOT support a feature SHOULD 135 | respond with HTTP status code 501 (Not Implemented). 136 | """ 137 | return HttpResponse(content_type=constants.SCIM_CONTENT_TYPE, status=501) 138 | 139 | def load_body(self, body): 140 | decoded = body.decode(constants.ENCODING) 141 | stripped = decoded.strip() or '{}' 142 | 143 | try: 144 | return json.loads(stripped) 145 | except json.decoder.JSONDecodeError as e: 146 | msg = 'Could not decode JSON body: ' + e.args[0] 147 | raise exceptions.BadRequestError(msg) 148 | 149 | 150 | class FilterMixin(object): 151 | 152 | parser_getter = None 153 | scim_adapter_getter = None 154 | 155 | def _page(self, request): 156 | try: 157 | start = request.GET.get('startIndex', 1) 158 | if start is not None: 159 | start = int(start) 160 | if start < 1: 161 | raise exceptions.BadRequestError('Invalid startIndex (must be >= 1)') 162 | 163 | count = request.GET.get('count', 50) 164 | if count is not None: 165 | count = int(count) 166 | 167 | return start, count 168 | 169 | except ValueError as e: 170 | raise exceptions.BadRequestError('Invalid pagination values: ' + str(e)) 171 | 172 | def _search(self, request, query, start, count): 173 | try: 174 | qs = self.__class__.parser_getter().search(query, request) 175 | except (ValueError, SCIMParserError) as e: 176 | raise exceptions.BadRequestError('Invalid filter/search query: ' + str(e)) 177 | 178 | extra_filter_kwargs = self.get_extra_filter_kwargs(request) 179 | qs = self._filter_raw_queryset_with_extra_filter_kwargs(qs, extra_filter_kwargs) 180 | extra_exclude_kwargs = self.get_extra_exclude_kwargs(request) 181 | qs = self._filter_raw_queryset_with_extra_exclude_kwargs(qs, extra_exclude_kwargs) 182 | 183 | return self._build_response(request, qs, start, count) 184 | 185 | def _get_nested_field(self, obj, attr_key): 186 | """Get a nested field for a given object, so 'a__b__c' returns a tuple with (obj.a.b.c, found)""" 187 | tokens = attr_key.split('__') 188 | for field_name in tokens: 189 | if not hasattr(obj, field_name): 190 | return None, False 191 | obj = getattr(obj, field_name) 192 | return obj, True 193 | 194 | def _filter_raw_queryset_with_extra_filter_kwargs(self, qs, extra_filter_kwargs): 195 | obj_list = [] 196 | for obj in qs: 197 | add_obj = True 198 | for attr_key, attr_val in extra_filter_kwargs.items(): 199 | if attr_key.endswith('__in'): 200 | attr_key = attr_key.replace('__in', '') 201 | else: 202 | attr_val = [attr_val] 203 | 204 | value, found = self._get_nested_field(obj, attr_key) 205 | if not found or value not in attr_val: 206 | add_obj = False 207 | break 208 | 209 | if add_obj: 210 | obj_list.append(obj) 211 | 212 | return obj_list 213 | 214 | def _filter_raw_queryset_with_extra_exclude_kwargs(self, qs, extra_exclude_kwargs): 215 | obj_list = [] 216 | for obj in qs: 217 | add_obj = True 218 | for attr_key, attr_val in extra_exclude_kwargs.items(): 219 | if attr_key.endswith('__in'): 220 | attr_key = attr_key.replace('__in', '') 221 | else: 222 | attr_val = [attr_val] 223 | 224 | value, found = self._get_nested_field(obj, attr_key) 225 | if found and value in attr_val: 226 | add_obj = False 227 | break 228 | 229 | if add_obj: 230 | obj_list.append(obj) 231 | 232 | return obj_list 233 | 234 | def _build_response(self, request, qs, start, count): 235 | try: 236 | total_count = sum(1 for _ in qs) 237 | qs = qs[start - 1:(start - 1) + count] 238 | resources = [self.scim_adapter(o, request=request).to_dict() for o in qs] 239 | doc = { 240 | 'schemas': [constants.SchemaURI.LIST_RESPONSE], 241 | 'totalResults': total_count, 242 | 'itemsPerPage': count, 243 | 'startIndex': start, 244 | 'Resources': resources, 245 | } 246 | except ValueError as e: 247 | raise exceptions.BadRequestError(str(e)) 248 | else: 249 | content = json.dumps(doc) 250 | return HttpResponse(content=content, 251 | content_type=constants.SCIM_CONTENT_TYPE) 252 | 253 | 254 | class SearchView(FilterMixin, SCIMView): 255 | http_method_names = ['post'] 256 | 257 | # override model class so correct extra_filter/exclude_kwarg getter is fetched 258 | model_cls = 'search' 259 | 260 | def post(self, request, *args, **kwargs): 261 | body = self.load_body(request.body) 262 | if body.get('schemas') != [constants.SchemaURI.SERACH_REQUEST]: 263 | raise exceptions.BadRequestError('Invalid schema uri. Must be SearchRequest.') 264 | 265 | query = body.get('filter', request.GET.get('filter')) 266 | 267 | if not query: 268 | raise exceptions.BadRequestError('No filter query specified') 269 | 270 | response = self._search(request, query, *self._page(request)) 271 | path = reverse(self.scim_adapter.url_name) 272 | url = urljoin(get_base_scim_location_getter()(request=request), path).rstrip('/') 273 | response['Location'] = url + '/.search' 274 | return response 275 | 276 | 277 | class UserSearchView(SearchView): 278 | scim_adapter_getter = get_user_adapter 279 | parser_getter = get_user_filter_parser 280 | 281 | 282 | class GroupSearchView(SearchView): 283 | scim_adapter_getter = get_group_adapter 284 | parser_getter = get_group_filter_parser 285 | 286 | 287 | class GetView(object): 288 | def get(self, request, *args, **kwargs): 289 | if kwargs.get(self.lookup_url_kwarg): 290 | return self.get_single(request) 291 | 292 | return self.get_many(request) 293 | 294 | def get_single(self, request): 295 | obj = self.get_object() 296 | scim_obj = self.scim_adapter(obj, request=request) 297 | content = json.dumps(scim_obj.to_dict()) 298 | response = HttpResponse(content=content, 299 | content_type=constants.SCIM_CONTENT_TYPE) 300 | response['Location'] = scim_obj.location 301 | return response 302 | 303 | def get_many(self, request): 304 | query = request.GET.get('filter') 305 | if query: 306 | return self._search(request, query, *self._page(request)) 307 | 308 | extra_filter_kwargs = self.get_extra_filter_kwargs(request) 309 | extra_exclude_kwargs = self.get_extra_exclude_kwargs(request) 310 | qs = self.model_cls.objects.filter( 311 | **extra_filter_kwargs 312 | ).exclude( 313 | **extra_exclude_kwargs 314 | ) 315 | qs = qs.order_by(self.lookup_field) 316 | qs = self.get_queryset_post_processor(request, qs) 317 | return self._build_response(request, qs, *self._page(request)) 318 | 319 | 320 | class DeleteView(object): 321 | def delete(self, request, *args, **kwargs): 322 | obj = self.get_object() 323 | 324 | scim_obj = self.scim_adapter(obj, request=request) 325 | 326 | scim_obj.delete() 327 | 328 | return HttpResponse(status=204) 329 | 330 | 331 | class PostView(object): 332 | def post(self, request, *args, **kwargs): 333 | obj = self.model_cls() 334 | scim_obj = self.scim_adapter(obj, request=request) 335 | 336 | body = self.load_body(request.body) 337 | 338 | if not body: 339 | raise exceptions.BadRequestError('POST call made with empty body') 340 | 341 | scim_obj.validate_dict(body) 342 | scim_obj.from_dict(body) 343 | 344 | try: 345 | scim_obj.save() 346 | except db.utils.IntegrityError as e: 347 | # Cast error to a SCIM IntegrityError to use the status 348 | # attribute on the SCIM IntegrityError. 349 | raise exceptions.IntegrityError(str(e)) 350 | 351 | content = json.dumps(scim_obj.to_dict()) 352 | response = HttpResponse(content=content, 353 | content_type=constants.SCIM_CONTENT_TYPE, 354 | status=201) 355 | response['Location'] = scim_obj.location 356 | return response 357 | 358 | 359 | class PutView(object): 360 | def put(self, request, *args, **kwargs): 361 | obj = self.get_object() 362 | 363 | scim_obj = self.scim_adapter(obj, request=request) 364 | 365 | body = self.load_body(request.body) 366 | 367 | if not body: 368 | raise exceptions.BadRequestError('PUT call made with empty body') 369 | 370 | scim_obj.validate_dict(body) 371 | scim_obj.from_dict(body) 372 | scim_obj.save() 373 | 374 | content = json.dumps(scim_obj.to_dict()) 375 | response = HttpResponse(content=content, 376 | content_type=constants.SCIM_CONTENT_TYPE) 377 | response['Location'] = scim_obj.location 378 | return response 379 | 380 | 381 | class PatchView(object): 382 | def patch(self, request, *args, **kwargs): 383 | obj = self.get_object() 384 | 385 | scim_obj = self.scim_adapter(obj, request=request) 386 | body = self.load_body(request.body) 387 | 388 | operations = body.get('Operations') 389 | 390 | if not operations: 391 | raise exceptions.BadRequestError('PATCH call made without operations array') 392 | 393 | with transaction.atomic(): 394 | scim_obj.handle_operations(operations) 395 | 396 | content = json.dumps(scim_obj.to_dict()) 397 | response = HttpResponse(content=content, 398 | content_type=constants.SCIM_CONTENT_TYPE) 399 | response['Location'] = scim_obj.location 400 | return response 401 | 402 | 403 | class UsersView(FilterMixin, GetView, PostView, PutView, PatchView, DeleteView, SCIMView): 404 | 405 | http_method_names = ['get', 'post', 'put', 'patch', 'delete'] 406 | 407 | scim_adapter_getter = get_user_adapter 408 | model_cls_getter = get_user_model 409 | parser_getter = get_user_filter_parser 410 | 411 | 412 | class GroupsView(FilterMixin, GetView, PostView, PutView, PatchView, DeleteView, SCIMView): 413 | 414 | http_method_names = ['get', 'post', 'put', 'patch', 'delete'] 415 | 416 | scim_adapter_getter = get_group_adapter 417 | model_cls_getter = get_group_model 418 | parser_getter = get_group_filter_parser 419 | 420 | 421 | class ServiceProviderConfigView(SCIMView): 422 | http_method_names = ['get'] 423 | 424 | def get(self, request): 425 | config = get_service_provider_config_model()(request=request) 426 | content = json.dumps(config.to_dict()) 427 | return HttpResponse(content=content, 428 | content_type=constants.SCIM_CONTENT_TYPE) 429 | 430 | 431 | class ResourceTypesView(SCIMView): 432 | 433 | http_method_names = ['get'] 434 | 435 | def type_dict_by_type_id(self, request): 436 | type_adapters = get_user_adapter(), get_group_adapter() 437 | type_dicts = [m.resource_type_dict(request) for m in type_adapters] 438 | return {d['id']: d for d in type_dicts} 439 | 440 | def get(self, request, uuid=None, *args, **kwargs): 441 | if uuid: 442 | doc = self.type_dict_by_type_id(request).get(uuid) 443 | if not doc: 444 | return HttpResponse(content_type=constants.SCIM_CONTENT_TYPE, status=404) 445 | 446 | else: 447 | key_func = lambda o: o.get('id') # noqa: E731 448 | type_dicts = self.type_dict_by_type_id(request).values() 449 | types = list(sorted(type_dicts, key=key_func)) 450 | doc = { 451 | 'schemas': [constants.SchemaURI.LIST_RESPONSE], 452 | 'Resources': types, 453 | } 454 | 455 | return HttpResponse(content=json.dumps(doc), 456 | content_type=constants.SCIM_CONTENT_TYPE) 457 | 458 | 459 | class SchemasView(SCIMView): 460 | 461 | http_method_names = ['get'] 462 | 463 | schemas_by_uri = {s['id']: s for s in get_all_schemas_getter()()} 464 | 465 | def get(self, request, uuid=None, *args, **kwargs): 466 | if uuid: 467 | doc = self.schemas_by_uri.get(uuid) 468 | if not doc: 469 | return HttpResponse(content_type=constants.SCIM_CONTENT_TYPE, status=404) 470 | 471 | else: 472 | key_func = lambda o: o.get('id') # noqa: E731 473 | schemas = list(sorted(self.schemas_by_uri.values(), key=key_func)) 474 | doc = { 475 | 'schemas': [constants.SchemaURI.LIST_RESPONSE], 476 | 'Resources': schemas, 477 | } 478 | 479 | content = json.dumps(doc) 480 | return HttpResponse(content=content, 481 | content_type=constants.SCIM_CONTENT_TYPE) 482 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/15five/django-scim2/078e85f034b9e083d4a98437277ac2f9fd49ae1d/tests/__init__.py -------------------------------------------------------------------------------- /tests/filters.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | 3 | from django_scim.filters import FilterQuery 4 | from django_scim.utils import get_group_model 5 | 6 | 7 | class UserFilterQuery(FilterQuery): 8 | model_getter = get_user_model 9 | attr_map = { 10 | ('userName', None, None): 'username', 11 | ('name', 'familyName', None): 'last_name', 12 | ('familyName', None, None): 'last_name', 13 | ('name', 'givenName', None): 'first_name', 14 | ('givenName', None, None): 'first_name', 15 | ('active', None, None): 'is_active', 16 | } 17 | 18 | 19 | class GroupFilterQuery(FilterQuery): 20 | model_getter = get_group_model 21 | attr_map = {} 22 | -------------------------------------------------------------------------------- /tests/hashers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.hashers import BasePasswordHasher 2 | 3 | 4 | class NoopPasswordHasher(BasePasswordHasher): 5 | """ 6 | A hasher that does not hash to speed up tests. 7 | """ 8 | algorithm = 'plain' 9 | 10 | def encode(self, password, salt): 11 | return '{}${}'.format(self.algorithm, password) 12 | 13 | def salt(self): 14 | return None 15 | 16 | def verify(self, password, encoded): 17 | algo, decoded = encoded.split('$', 1) 18 | return password == decoded 19 | 20 | def safe_summary(self, encoded): 21 | return {'desc': 'Not hashed'} 22 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import connection, models 3 | 4 | from django_scim import models as scim_models 5 | 6 | 7 | class TestGroup(scim_models.AbstractSCIMGroupMixin): 8 | name = models.CharField('name', max_length=80, unique=True) 9 | 10 | class Meta: 11 | app_label = 'django_scim' 12 | 13 | 14 | class TestUser(scim_models.AbstractSCIMUserMixin, AbstractUser): 15 | scim_groups = models.ManyToManyField( 16 | TestGroup, 17 | related_name="user_set", 18 | ) 19 | 20 | class Meta: 21 | app_label = 'django_scim' 22 | 23 | 24 | def get_group_model(): 25 | return TestGroup 26 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for tests. 3 | """ 4 | import logging 5 | 6 | # SECURITY WARNING: keep the secret key used in production secret! 7 | SECRET_KEY = '+7y-ny9+un-bv60)6d^-1n-4ozv4p3bup9sl5$v24@0m5yh7n@' 8 | 9 | # SECURITY WARNING: don't run with debug turned on in production! 10 | DEBUG = True 11 | 12 | ALLOWED_HOSTS = ['*'] 13 | 14 | # Application definition 15 | 16 | INSTALLED_APPS = ( 17 | 'django.contrib.admin', 18 | 'django.contrib.auth', 19 | 'django.contrib.contenttypes', 20 | 'django.contrib.sessions', 21 | 'django.contrib.messages', 22 | 'django.contrib.staticfiles', 23 | 'django_scim', 24 | ) 25 | 26 | MIDDLEWARE = ( 27 | 'django.contrib.sessions.middleware.SessionMiddleware', 28 | 'django.middleware.common.CommonMiddleware', 29 | 'django.middleware.csrf.CsrfViewMiddleware', 30 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 31 | 'django.contrib.messages.middleware.MessageMiddleware', 32 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 33 | 'django.middleware.security.SecurityMiddleware', 34 | ) 35 | 36 | ROOT_URLCONF = 'tests.urls' 37 | 38 | TEMPLATES = [ 39 | { 40 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 41 | 'DIRS': [], 42 | 'APP_DIRS': True, 43 | 'OPTIONS': { 44 | 'context_processors': [ 45 | 'django.template.context_processors.debug', 46 | 'django.template.context_processors.request', 47 | 'django.contrib.auth.context_processors.auth', 48 | 'django.contrib.messages.context_processors.messages', 49 | ], 50 | }, 51 | }, 52 | ] 53 | 54 | # Database 55 | 56 | DATABASES = { 57 | 'default': { 58 | 'ENGINE': 'django.db.backends.sqlite3', 59 | 'NAME': ':memory:', 60 | } 61 | } 62 | 63 | CACHES = { 64 | 'default': { 65 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 66 | 'LOCATION': 'unique-snowflake' 67 | } 68 | } 69 | 70 | logging.disable(logging.CRITICAL) 71 | 72 | # Internationalization 73 | 74 | LANGUAGE_CODE = 'en-us' 75 | 76 | TIME_ZONE = 'UTC' 77 | 78 | USE_I18N = True 79 | 80 | USE_TZ = True 81 | 82 | # Static files (CSS, JavaScript, Images) 83 | 84 | STATIC_URL = '/static/' 85 | 86 | # Speed up tests with noop password hasher 87 | PASSWORD_HASHERS = ('tests.hashers.NoopPasswordHasher',) 88 | 89 | 90 | # -- Django SCIM specific settings -- 91 | 92 | SCIM_SERVICE_PROVIDER = { 93 | 'USER_FILTER_PARSER': 'tests.filters.UserFilterQuery', 94 | 'GROUP_FILTER_PARSER': 'tests.filters.GroupFilterQuery', 95 | 'NETLOC': 'localhost', 96 | 'AUTHENTICATION_SCHEMES': [ 97 | { 98 | 'type': 'oauth2', 99 | 'name': 'OAuth 2', 100 | 'description': 'Oauth 2 implemented with bearer token', 101 | 'specUri': '', 102 | 'documentationUri': '', 103 | }, 104 | ], 105 | } 106 | -------------------------------------------------------------------------------- /tests/test_adapters.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import RequestFactory, TestCase, override_settings 4 | from scim2_filter_parser.attr_paths import AttrPath 5 | 6 | from django_scim import constants 7 | from django_scim.adapters import SCIMMixin 8 | from django_scim.utils import get_group_adapter, get_user_adapter, get_user_model 9 | 10 | from tests.models import get_group_model 11 | 12 | 13 | @override_settings(AUTH_USER_MODEL='django_scim.TestUser') 14 | class SCIMUserTestCase(TestCase): 15 | maxDiff = None 16 | request = RequestFactory().get('/fake/request') 17 | 18 | def test_display_name(self): 19 | ford = get_user_model().objects.create( 20 | first_name='Robert', 21 | last_name='Ford', 22 | username='rford', 23 | ) 24 | ford = get_user_adapter()(ford, self.request) 25 | 26 | self.assertEqual(ford.display_name, 'Robert Ford') 27 | 28 | def test_emails(self): 29 | ford = get_user_model().objects.create( 30 | first_name='Robert', 31 | last_name='Ford', 32 | username='rford', 33 | email='rford@ww.com', 34 | ) 35 | ford = get_user_adapter()(ford, self.request) 36 | 37 | self.assertEqual( 38 | ford.emails, 39 | [{'primary': True, 'value': 'rford@ww.com'}] 40 | ) 41 | 42 | def test_groups(self): 43 | behavior = get_group_model().objects.create( 44 | name='Behavior Group', 45 | ) 46 | ford = get_user_model().objects.create( 47 | first_name='Robert', 48 | last_name='Ford', 49 | username='rford', 50 | ) 51 | ford.scim_groups.add(behavior) 52 | ford = get_user_adapter()(ford, self.request) 53 | 54 | expected = [ 55 | { 56 | 'display': u'Behavior Group', 57 | 'value': '1', 58 | '$ref': u'https://localhost/scim/v2/Groups/1', 59 | } 60 | ] 61 | 62 | self.assertEqual(ford.groups, expected) 63 | 64 | def test_meta(self): 65 | ford = get_user_model().objects.create( 66 | first_name='Robert', 67 | last_name='Ford', 68 | username='rford', 69 | email='rford@ww.com', 70 | ) 71 | 72 | expected = { 73 | 'resourceType': 'User', 74 | 'lastModified': ford.date_joined.isoformat(), 75 | 'location': u'https://localhost/scim/v2/Users/1', 76 | 'created': ford.date_joined.isoformat(), 77 | } 78 | 79 | ford = get_user_adapter()(ford, self.request) 80 | self.assertEqual(ford.meta, expected) 81 | 82 | def test_to_dict(self): 83 | behavior = get_group_model().objects.create( 84 | name='Behavior Group', 85 | ) 86 | ford = get_user_model().objects.create( 87 | first_name='Robert', 88 | last_name='Ford', 89 | username='rford', 90 | email='rford@ww.com', 91 | scim_external_id='Anthony.Hopkins', 92 | ) 93 | ford.scim_groups.add(behavior) 94 | 95 | expected = { 96 | 'schemas': [constants.SchemaURI.USER], 97 | 'userName': 'rford', 98 | 'meta': { 99 | 'resourceType': 'User', 100 | 'lastModified': ford.date_joined.isoformat(), 101 | 'location': u'https://localhost/scim/v2/Users/1', 102 | 'created': ford.date_joined.isoformat(), 103 | }, 104 | 'displayName': u'Robert Ford', 105 | 'name': { 106 | 'givenName': 'Robert', 107 | 'familyName': 'Ford', 108 | 'formatted': 'Robert Ford', 109 | }, 110 | 'groups': [ 111 | { 112 | 'display': u'Behavior Group', 113 | 'value': '1', 114 | '$ref': u'https://localhost/scim/v2/Groups/1' 115 | } 116 | ], 117 | 'active': True, 118 | 'id': '1', 119 | 'emails': [{'primary': True, 'value': 'rford@ww.com'}], 120 | 'externalId': 'Anthony.Hopkins', 121 | } 122 | 123 | ford = get_user_adapter()(ford, self.request) 124 | self.assertEqual(ford.to_dict(), expected) 125 | 126 | def test_resource_type_dict(self): 127 | ford = get_user_model().objects.create( 128 | first_name='Robert', 129 | last_name='Ford', 130 | username='rford', 131 | email='rford@ww.com', 132 | ) 133 | ford = get_user_adapter()(ford, self.request) 134 | 135 | expected = { 136 | 'endpoint': u'/scim/v2/Users', 137 | 'description': 'User Account', 138 | 'name': 'User', 139 | 'meta': { 140 | 'resourceType': 'ResourceType', 141 | 'location': u'https://localhost/scim/v2/ResourceTypes/User' 142 | }, 143 | 'schemas': [constants.SchemaURI.RESOURCE_TYPE], 144 | 'id': 'User', 145 | 'schema': constants.SchemaURI.USER, 146 | } 147 | 148 | self.assertEqual(ford.resource_type_dict(), expected) 149 | 150 | 151 | @override_settings(AUTH_USER_MODEL='django_scim.TestUser') 152 | class SCIMHandleOperationsTestCase(TestCase): 153 | maxDiff = None 154 | request = RequestFactory().get('/fake/request') 155 | 156 | def test_handle_replace_simple(self): 157 | operations = [ 158 | { 159 | "op": "Replace", 160 | "path": "externalId", 161 | "value": "Robert.Ford" 162 | }, 163 | ] 164 | 165 | ford = get_user_model().objects.create( 166 | first_name='Robert', 167 | last_name='Ford', 168 | username='rford', 169 | email='rford@ww.com', 170 | ) 171 | ford = get_user_adapter()(ford, self.request) 172 | 173 | expected = ( 174 | ('externalId', None, None), 175 | 'Robert.Ford', 176 | operations[0] 177 | ) 178 | 179 | with patch('django_scim.adapters.SCIMUser.handle_replace') as handler: 180 | ford.handle_operations(operations) 181 | call_args = handler.call_args[0] 182 | self.assertIsInstance(call_args[0], AttrPath) 183 | self.assertEqual(call_args[0].first_path, expected[0]) 184 | self.assertEqual(call_args[1], expected[1]) 185 | self.assertEqual(call_args[2], expected[2]) 186 | 187 | def test_handle_replace_complex(self): 188 | operations = [ 189 | { 190 | "op": "Replace", 191 | "path": "name.givenName", 192 | "value": "Robert" 193 | }, 194 | ] 195 | 196 | ford = get_user_model().objects.create( 197 | first_name='Robert', 198 | last_name='Ford', 199 | username='rford', 200 | email='rford@ww.com', 201 | ) 202 | ford = get_user_adapter()(ford, self.request) 203 | 204 | expected = ( 205 | ('name', 'givenName', None), 206 | 'Robert', 207 | operations[0] 208 | ) 209 | 210 | with patch('django_scim.adapters.SCIMUser.handle_replace') as handler: 211 | ford.handle_operations(operations) 212 | call_args = handler.call_args[0] 213 | self.assertIsInstance(call_args[0], AttrPath) 214 | self.assertEqual(call_args[0].first_path, expected[0]) 215 | self.assertEqual(call_args[1], expected[1]) 216 | self.assertEqual(call_args[2], expected[2]) 217 | 218 | def test_handle_add_simple(self): 219 | operations = [ 220 | { 221 | "op": "Add", 222 | "path": "externalId", 223 | "value": "Robert.Ford" 224 | }, 225 | ] 226 | 227 | ford = get_user_model().objects.create( 228 | first_name='Robert', 229 | last_name='Ford', 230 | username='rford', 231 | email='rford@ww.com', 232 | ) 233 | ford = get_user_adapter()(ford, self.request) 234 | 235 | expected = ( 236 | ('externalId', None, None), 237 | 'Robert.Ford', 238 | operations[0] 239 | ) 240 | 241 | with patch('django_scim.adapters.SCIMUser.handle_add') as handler: 242 | ford.handle_operations(operations) 243 | call_args = handler.call_args[0] 244 | self.assertIsInstance(call_args[0], AttrPath) 245 | self.assertEqual(call_args[0].first_path, expected[0]) 246 | self.assertEqual(call_args[1], expected[1]) 247 | self.assertEqual(call_args[2], expected[2]) 248 | 249 | def test_handle_add_complex_1(self): 250 | operations = [ 251 | { 252 | "op": "Add", 253 | "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", 254 | "value": "Design" 255 | } 256 | ] 257 | 258 | ford = get_user_model().objects.create( 259 | first_name='Robert', 260 | last_name='Ford', 261 | username='rford', 262 | email='rford@ww.com', 263 | ) 264 | ford = get_user_adapter()(ford, self.request) 265 | 266 | expected = ( 267 | ('department', None, 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'), 268 | 'Design', 269 | operations[0] 270 | ) 271 | 272 | with patch('django_scim.adapters.SCIMUser.handle_add') as handler: 273 | ford.handle_operations(operations) 274 | 275 | call_args = handler.call_args[0] 276 | self.assertIsInstance(call_args[0], AttrPath) 277 | self.assertEqual(call_args[0].first_path, expected[0]) 278 | self.assertEqual(call_args[1], expected[1]) 279 | self.assertEqual(call_args[2], expected[2]) 280 | 281 | def test_handle_add_complex_2(self): 282 | operations = [ 283 | { 284 | "op": "Add", 285 | "path": "addresses[type eq \"work\"].country", 286 | "value": "Sector 9" 287 | } 288 | ] 289 | 290 | ford = get_user_model().objects.create( 291 | first_name='Robert', 292 | last_name='Ford', 293 | username='rford', 294 | email='rford@ww.com', 295 | ) 296 | ford = get_user_adapter()(ford, self.request) 297 | 298 | expected = ( 299 | ('addresses', 'country', None), 300 | 'Sector 9', 301 | operations[0] 302 | ) 303 | 304 | with patch('django_scim.adapters.SCIMUser.handle_add') as handler: 305 | ford.handle_operations(operations) 306 | path_obj, value, op_dict = handler.call_args[0] 307 | self.assertEqual(path_obj.filter, 'addresses[type eq \"work\"].country eq ""') 308 | self.assertEqual( 309 | list(path_obj), 310 | [('addresses', 'type', None), ('addresses', 'country', None)] 311 | ) 312 | self.assertEqual(value, 'Sector 9') 313 | self.assertEqual(op_dict, operations[0]) 314 | 315 | def test_handle_add_complex_3(self): 316 | operations = [ 317 | { 318 | "op": "Add", 319 | "path": 'members[value eq "6784"]', 320 | "value": "[]" 321 | } 322 | ] 323 | 324 | ford = get_user_model().objects.create( 325 | first_name='Robert', 326 | last_name='Ford', 327 | username='rford', 328 | email='rford@ww.com', 329 | ) 330 | ford = get_user_adapter()(ford, self.request) 331 | 332 | expected = ( 333 | ('addresses', 'country', None), 334 | 'Sector 9', 335 | operations[0] 336 | ) 337 | 338 | with patch('django_scim.adapters.SCIMUser.handle_add') as handler: 339 | ford.handle_operations(operations) 340 | path_obj, value, op_dict = handler.call_args[0] 341 | self.assertEqual(path_obj.filter, 'members[value eq "6784"] eq ""') 342 | self.assertEqual( 343 | list(path_obj), 344 | [('members', 'value', None), ('members', None, None)] 345 | ) 346 | self.assertEqual(value, '[]') 347 | self.assertEqual(op_dict, operations[0]) 348 | 349 | 350 | class SCIMMixinPathParserTestCase(TestCase): 351 | maxDiff = None 352 | 353 | def test_azure_ad_style_paths(self): 354 | """ 355 | Test paths typically sent by AzureAD. 356 | """ 357 | paths_and_values = [ 358 | ('addresses[type eq \"work\"].country', 1), 359 | ('addresses[type eq \"work\"].locality', 1), 360 | ('addresses[type eq \"work\"].postalCode', 1), 361 | ('addresses[type eq \"work\"].streetAddress', 1), 362 | ] 363 | 364 | expected_paths_and_values = [ 365 | { 366 | 'path': 'addresses[type eq \"work\"].country eq ""', 367 | 'attr_paths': [('addresses', 'type', None), ('addresses', 'country', None)] 368 | }, 369 | { 370 | 'path': 'addresses[type eq \"work\"].locality eq ""', 371 | 'attr_paths': [('addresses', 'type', None), ('addresses', 'locality', None)] 372 | }, 373 | { 374 | 'path': 'addresses[type eq \"work\"].postalCode eq ""', 375 | 'attr_paths': [('addresses', 'type', None), ('addresses', 'postalCode', None)] 376 | }, 377 | { 378 | 'path': 'addresses[type eq \"work\"].streetAddress eq ""', 379 | 'attr_paths': [('addresses', 'type', None), ('addresses', 'streetAddress', None)] 380 | } 381 | ] 382 | 383 | 384 | func = SCIMMixin(None).parse_path_and_values 385 | result_paths = list(map(lambda x: func(*x), paths_and_values)) 386 | for paths_and_values, expected in zip(result_paths, expected_paths_and_values): 387 | path_obj, _ = paths_and_values[0] 388 | self.assertEqual(path_obj.filter, expected['path']) 389 | self.assertEqual(list(path_obj), expected['attr_paths']) 390 | 391 | def test_correct_path_tuples(self): 392 | """ 393 | Test paths regex 394 | """ 395 | paths_and_expected_values = [ 396 | ( 397 | 'externalId', 398 | ('externalId', None, None), 399 | ), 400 | ( 401 | 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department', 402 | ('department', None, 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'), 403 | ), 404 | ( 405 | 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:name.familyName', 406 | ('name', 'familyName', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'), 407 | ), 408 | ] 409 | 410 | func = SCIMMixin(None).parse_path_and_values 411 | for path, expected_result in paths_and_expected_values: 412 | result, _ = func(path, None)[0] 413 | self.assertEqual(result.first_path, expected_result) 414 | 415 | 416 | @override_settings(AUTH_USER_MODEL='django_scim.TestUser') 417 | class SCIMGroupTestCase(TestCase): 418 | request = RequestFactory().get('/fake/request') 419 | 420 | def test_display_name(self): 421 | behavior = get_group_model().objects.create( 422 | name='Behavior Group', 423 | ) 424 | behavior = get_group_adapter()(behavior, self.request) 425 | self.assertEqual(behavior.display_name, 'Behavior Group') 426 | 427 | def test_members(self): 428 | behavior = get_group_model().objects.create( 429 | name='Behavior Group', 430 | ) 431 | ford = get_user_model().objects.create( 432 | first_name='Robert', 433 | last_name='Ford', 434 | username='rford', 435 | email='rford@ww.com', 436 | ) 437 | ford.scim_groups.add(behavior) 438 | 439 | RequestFactory() 440 | behavior = get_group_adapter()(behavior, self.request) 441 | 442 | expected = [ 443 | { 444 | 'display': u'Robert Ford', 445 | 'value': '1', 446 | '$ref': u'https://localhost/scim/v2/Users/1' 447 | } 448 | ] 449 | 450 | self.assertEqual(behavior.members, expected) 451 | 452 | def test_meta(self): 453 | behavior = get_group_model().objects.create( 454 | name='Behavior Group', 455 | ) 456 | behavior = get_group_adapter()(behavior, self.request) 457 | 458 | expected = { 459 | 'resourceType': 'Group', 460 | 'location': u'https://localhost/scim/v2/Groups/1' 461 | } 462 | 463 | self.assertEqual(behavior.meta, expected) 464 | 465 | def test_to_dict(self): 466 | behavior = get_group_model().objects.create( 467 | name='Behavior Group', 468 | scim_external_id='ww.bg', 469 | ) 470 | ford = get_user_model().objects.create( 471 | first_name='Robert', 472 | last_name='Ford', 473 | username='rford', 474 | email='rford@ww.com', 475 | ) 476 | ford.scim_groups.add(behavior) 477 | 478 | expected = { 479 | 'meta': { 480 | 'resourceType': 'Group', 481 | 'location': u'https://localhost/scim/v2/Groups/1' 482 | }, 483 | 'displayName': 'Behavior Group', 484 | 'id': '1', 485 | 'members': [ 486 | { 487 | 'display': u'Robert Ford', 488 | 'value': '1', 489 | '$ref': u'https://localhost/scim/v2/Users/1' 490 | } 491 | ], 492 | 'schemas': [constants.SchemaURI.GROUP], 493 | 'externalId': 'ww.bg', 494 | } 495 | 496 | behavior = get_group_adapter()(behavior, self.request) 497 | self.assertEqual(behavior.to_dict(), expected) 498 | 499 | def test_resource_type_dict(self): 500 | behavior = get_group_model().objects.create( 501 | name='Behavior Group', 502 | ) 503 | behavior = get_group_adapter()(behavior, self.request) 504 | 505 | expected = { 506 | 'endpoint': u'/scim/v2/Groups', 507 | 'description': 'Group', 508 | 'name': 'Group', 509 | 'meta': { 510 | 'resourceType': 'ResourceType', 511 | 'location': u'https://localhost/scim/v2/ResourceTypes/Group' 512 | }, 513 | 'schemas': [constants.SchemaURI.RESOURCE_TYPE], 514 | 'id': 'Group', 515 | 'schema': constants.SchemaURI.GROUP 516 | } 517 | 518 | self.assertEqual(behavior.resource_type_dict(), expected) 519 | -------------------------------------------------------------------------------- /tests/test_filters.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from tests.filters import UserFilterQuery 4 | from django_scim.utils import get_user_model 5 | 6 | 7 | class Users(TestCase): 8 | parser = UserFilterQuery 9 | 10 | def setUp(self): 11 | self.ford = get_user_model().objects.create( 12 | first_name='Robert', 13 | last_name='Ford', 14 | username='rford', 15 | email='rford@ww.com', 16 | ) 17 | self.abernathy = get_user_model().objects.create( 18 | first_name='Dolores', 19 | last_name='Abernathy', 20 | username='dabernathy', 21 | ) 22 | 23 | def test_username_eq(self): 24 | query = 'userName eq "rford"' 25 | qs = list(self.parser.search(query)) 26 | expected = [self.ford] 27 | self.assertEqual(qs, expected) 28 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.contrib.auth.models import AnonymousUser 4 | from django.test import RequestFactory, TestCase 5 | 6 | from django_scim.middleware import SCIMAuthCheckMiddleware 7 | 8 | 9 | class SCIMMiddlewareTestCase(TestCase): 10 | def test_middleware_for_django2(self): 11 | """ 12 | Regression test for https://github.com/15five/django-scim2/issues/13 13 | """ 14 | middleware = SCIMAuthCheckMiddleware() 15 | 16 | request = RequestFactory().get(middleware.reverse_url) 17 | request.user = AnonymousUser() 18 | 19 | response = middleware.process_request(request) 20 | self.assertEqual(response.status_code, 401) 21 | 22 | @mock.patch('django_scim.middleware.SCIMAuthCheckMiddleware.log_request') 23 | def test_log_called_only_for_scim_calls_request(self, log_func): 24 | middleware = SCIMAuthCheckMiddleware() 25 | 26 | request = RequestFactory().get('/') 27 | middleware.process_request(request) 28 | log_func.assert_not_called() 29 | 30 | request = RequestFactory().get(middleware.reverse_url) 31 | middleware.process_request(request) 32 | log_func.assert_called() 33 | 34 | @mock.patch('django_scim.middleware.SCIMAuthCheckMiddleware.log_response') 35 | def test_log_called_only_for_scim_calls_response(self, log_func): 36 | middleware = SCIMAuthCheckMiddleware() 37 | 38 | request = RequestFactory().get('/') 39 | middleware.process_response(request, None) 40 | log_func.assert_not_called() 41 | 42 | request = RequestFactory().get(middleware.reverse_url) 43 | middleware.process_response(request, None) 44 | log_func.assert_called() 45 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.test import TestCase, override_settings 3 | 4 | from django_scim import constants 5 | from django_scim.utils import get_service_provider_config_model, get_user_model 6 | 7 | # Force loading of test.models so its models are registered with Django and 8 | # testing framework. 9 | from tests.models import get_group_model as _unused 10 | 11 | 12 | class SCIMServiceProviderConfigTestCase(TestCase): 13 | maxDiff = None 14 | 15 | def test_meta(self): 16 | config = get_service_provider_config_model()() 17 | expected = { 18 | 'resourceType': 'ServiceProviderConfig', 19 | 'location': u'https://localhost/scim/v2/ServiceProviderConfig', 20 | } 21 | self.assertEqual(config.meta, expected) 22 | 23 | def test_location(self): 24 | config = get_service_provider_config_model()() 25 | location = 'https://localhost/scim/v2/ServiceProviderConfig' 26 | self.assertEqual(config.location, location) 27 | 28 | def test_to_dict(self): 29 | config = get_service_provider_config_model()() 30 | expected = { 31 | 'authenticationSchemes': [ 32 | { 33 | 'description': 'Oauth 2 implemented with bearer token', 34 | 'documentationUri': '', 35 | 'name': 'OAuth 2', 36 | 'specUri': '', 37 | 'type': 'oauth2' 38 | } 39 | ], 40 | 'bulk': { 41 | 'supported': False, 42 | 'maxPayloadSize': 1048576, 43 | 'maxOperations': 1000, 44 | }, 45 | 'changePassword': {'supported': True}, 46 | 'documentationUri': None, 47 | 'etag': {'supported': False}, 48 | 'filter': { 49 | 'supported': False, 50 | 'maxResults': 50 51 | }, 52 | 'meta': { 53 | 'location': u'https://localhost/scim/v2/ServiceProviderConfig', 54 | 'resourceType': 'ServiceProviderConfig' 55 | }, 56 | 'patch': {'supported': True}, 57 | 'schemas': [constants.SchemaURI.SERVICE_PROVIDER_CONFIG], 58 | 'sort': {'supported': False} 59 | } 60 | self.assertEqual(config.to_dict(), expected) 61 | 62 | 63 | @override_settings(AUTH_USER_MODEL='django_scim.TestUser') 64 | class UserTestCase(TestCase): 65 | maxDiff = None 66 | 67 | def test_get_user_by_id_duplicate_none_scim_id(self): 68 | """ 69 | Test GET /Users/{id} 70 | """ 71 | # create user 72 | ford = get_user_model().objects.create( 73 | username='robert.ford', 74 | first_name='Robert', 75 | last_name='Ford', 76 | ) 77 | ford.scim_id = None 78 | ford.save() 79 | 80 | # create user with duplicate scim_id that is None 81 | ford2 = get_user_model().objects.create( 82 | username='robert2.ford2', 83 | first_name='Robert2', 84 | last_name='Ford2', 85 | ) 86 | ford2.scim_id = None 87 | ford2.save() 88 | 89 | def test_get_user_by_id_duplicate_value_scim_id(self): 90 | """ 91 | Test GET /Users/{id} 92 | """ 93 | # create user 94 | ford = get_user_model().objects.create( 95 | username='robert.ford', 96 | first_name='Robert', 97 | last_name='Ford', 98 | ) 99 | scim_id = ford.scim_id 100 | 101 | # create user with duplicate scim_id 102 | ford2 = get_user_model().objects.create( 103 | username='robert2.ford2', 104 | first_name='Robert2', 105 | last_name='Ford2', 106 | ) 107 | ford2.scim_id = scim_id 108 | 109 | with self.assertRaises(django.db.utils.IntegrityError): 110 | ford2.save() 111 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.test import TestCase 4 | 5 | from django_scim.utils import get_loggable_body 6 | 7 | 8 | class LogCleanerTestCase(TestCase): 9 | 10 | def test_get_loggable_body(self): 11 | text = ('{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],' 12 | '"Operations":[{"op":"replace","value":{"password":"Lstar99&"}}]}') 13 | result = json.loads(get_loggable_body(text)) 14 | expected = { 15 | "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], 16 | "Operations": [{"op": "replace", "value": {"password": "********"}}] 17 | } 18 | self.assertEqual(result, expected) 19 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import path 3 | from django.urls import include 4 | 5 | urlpatterns = [ 6 | path('scim/v2/', include('django_scim.urls')), 7 | ] 8 | 9 | except ImportError: 10 | from django.conf.urls import url as re_path 11 | from django.conf.urls import include 12 | 13 | urlpatterns = [ 14 | re_path(r'^scim/v2/', include('django_scim.urls')), 15 | ] 16 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | py{39,310,311,312}-django{42} 5 | py{310,311,312}-django{50} 6 | py{310,311,312,313}-django{51} 7 | flake8 8 | coverage 9 | 10 | [gh-actions] 11 | python = 12 | 3.9: py39 13 | 3.10: py310 14 | 3.11: py311 15 | 3.12: py312 16 | 3.13: py313 17 | 18 | [testenv] 19 | setenv = 20 | PYTHONDONTWRITEBYTECODE=1 21 | DJANGO_SETTINGS_MODULE=tests.settings 22 | basepython = 23 | py39: python3.9 24 | py310: python3.10 25 | py311: python3.11 26 | py312: python3.12 27 | py313: python3.13 28 | .package: python3 29 | deps = 30 | django42: Django>=4.2,<5.0 31 | django50: Django>=5.0,<5.1 32 | django51: Django>=5.1,<5.2 33 | allowlist_externals = poetry 34 | commands = 35 | poetry install -v 36 | poetry run pytest tests/ 37 | 38 | [testenv:flake8] 39 | basepython = 40 | python3.11 41 | commands = 42 | poetry install -v 43 | poetry run flake8 src 44 | 45 | [testenv:coverage] 46 | basepython = 47 | python3.11 48 | commands = 49 | poetry install -v 50 | poetry run pytest --cov=django_scim --cov-report=xml --cov-report=term 51 | --------------------------------------------------------------------------------