django-pyoidc-keycloak-extensions 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. django_pyoidc_keycloak_extensions-0.2.2/.gitignore +164 -0
  2. django_pyoidc_keycloak_extensions-0.2.2/LICENSE +21 -0
  3. django_pyoidc_keycloak_extensions-0.2.2/PKG-INFO +342 -0
  4. django_pyoidc_keycloak_extensions-0.2.2/PLAN.md +628 -0
  5. django_pyoidc_keycloak_extensions-0.2.2/README.md +301 -0
  6. django_pyoidc_keycloak_extensions-0.2.2/manage.py +11 -0
  7. django_pyoidc_keycloak_extensions-0.2.2/pyproject.toml +95 -0
  8. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/__init__.py +5 -0
  9. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin.py +373 -0
  10. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin_api/__init__.py +0 -0
  11. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin_api/client.py +349 -0
  12. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin_api/exceptions.py +43 -0
  13. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin_api/provider.py +148 -0
  14. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/apps.py +66 -0
  15. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/backends.py +78 -0
  16. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/checks.py +211 -0
  17. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/conf.py +86 -0
  18. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/hooks.py +167 -0
  19. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/__init__.py +0 -0
  20. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/__init__.py +0 -0
  21. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/keycloak_purge_tokens.py +23 -0
  22. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/keycloak_reconcile.py +46 -0
  23. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/keycloak_sync_events.py +34 -0
  24. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/keycloak_sync_user.py +48 -0
  25. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/managers.py +59 -0
  26. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/migrations/0001_initial.py +160 -0
  27. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/migrations/0002_alter_groupmembership_source.py +18 -0
  28. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/migrations/__init__.py +0 -0
  29. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/__init__.py +30 -0
  30. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/base.py +208 -0
  31. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/concrete.py +33 -0
  32. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/sync.py +93 -0
  33. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/tokens.py +87 -0
  34. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/permissions.py +123 -0
  35. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/scrub.py +67 -0
  36. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/signals.py +29 -0
  37. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/__init__.py +0 -0
  38. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/events.py +297 -0
  39. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/groups.py +224 -0
  40. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/reconcile.py +160 -0
  41. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/runs.py +70 -0
  42. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/usernames.py +90 -0
  43. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/users.py +286 -0
  44. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tasks.py +77 -0
  45. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/templates/admin/keycloak/keycloakuser/change_form.html +17 -0
  46. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/__init__.py +0 -0
  47. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/exchange.py +118 -0
  48. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/extract.py +121 -0
  49. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/fields.py +38 -0
  50. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/refresh.py +216 -0
  51. django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/store.py +164 -0
  52. django_pyoidc_keycloak_extensions-0.2.2/tests/__init__.py +0 -0
  53. django_pyoidc_keycloak_extensions-0.2.2/tests/conftest.py +56 -0
  54. django_pyoidc_keycloak_extensions-0.2.2/tests/integration/__init__.py +0 -0
  55. django_pyoidc_keycloak_extensions-0.2.2/tests/integration/conftest.py +207 -0
  56. django_pyoidc_keycloak_extensions-0.2.2/tests/integration/realm-export.json +183 -0
  57. django_pyoidc_keycloak_extensions-0.2.2/tests/integration/test_keycloak_integration.py +285 -0
  58. django_pyoidc_keycloak_extensions-0.2.2/tests/test_admin_client.py +285 -0
  59. django_pyoidc_keycloak_extensions-0.2.2/tests/test_authorization.py +124 -0
  60. django_pyoidc_keycloak_extensions-0.2.2/tests/test_checks_and_admin.py +367 -0
  61. django_pyoidc_keycloak_extensions-0.2.2/tests/test_events.py +211 -0
  62. django_pyoidc_keycloak_extensions-0.2.2/tests/test_extract_real_pyoidc.py +111 -0
  63. django_pyoidc_keycloak_extensions-0.2.2/tests/test_groups.py +172 -0
  64. django_pyoidc_keycloak_extensions-0.2.2/tests/test_hooks.py +175 -0
  65. django_pyoidc_keycloak_extensions-0.2.2/tests/test_logging.py +291 -0
  66. django_pyoidc_keycloak_extensions-0.2.2/tests/test_reconcile.py +159 -0
  67. django_pyoidc_keycloak_extensions-0.2.2/tests/test_secret_handling.py +119 -0
  68. django_pyoidc_keycloak_extensions-0.2.2/tests/test_security_regressions.py +213 -0
  69. django_pyoidc_keycloak_extensions-0.2.2/tests/test_session_backend.py +143 -0
  70. django_pyoidc_keycloak_extensions-0.2.2/tests/test_sync_users.py +214 -0
  71. django_pyoidc_keycloak_extensions-0.2.2/tests/test_tokens.py +375 -0
  72. django_pyoidc_keycloak_extensions-0.2.2/tests/test_usernames.py +66 -0
  73. django_pyoidc_keycloak_extensions-0.2.2/tests/testapp/__init__.py +0 -0
  74. django_pyoidc_keycloak_extensions-0.2.2/tests/testapp/migrations/0001_initial.py +33 -0
  75. django_pyoidc_keycloak_extensions-0.2.2/tests/testapp/migrations/__init__.py +0 -0
  76. django_pyoidc_keycloak_extensions-0.2.2/tests/testapp/models.py +26 -0
  77. django_pyoidc_keycloak_extensions-0.2.2/tests/testproject/__init__.py +0 -0
  78. django_pyoidc_keycloak_extensions-0.2.2/tests/testproject/backend.py +49 -0
  79. django_pyoidc_keycloak_extensions-0.2.2/tests/testproject/settings.py +86 -0
  80. django_pyoidc_keycloak_extensions-0.2.2/tests/testproject/urls.py +4 -0
@@ -0,0 +1,164 @@
1
+ ### Python template
2
+ # Byte-compiled / optimized / DLL files
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+
7
+ # C extensions
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
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
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ #Pipfile.lock
97
+
98
+ # poetry
99
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
103
+ #poetry.lock
104
+
105
+ # pdm
106
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
107
+ #pdm.lock
108
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
109
+ # in version control.
110
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
111
+ .pdm.toml
112
+ .pdm-python
113
+ .pdm-build/
114
+
115
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116
+ __pypackages__/
117
+
118
+ # Celery stuff
119
+ celerybeat-schedule
120
+ celerybeat.pid
121
+
122
+ # SageMath parsed files
123
+ *.sage.py
124
+
125
+ # Environments
126
+ .env
127
+ .venv
128
+ env/
129
+ venv/
130
+ ENV/
131
+ env.bak/
132
+ venv.bak/
133
+
134
+ # Spyder project settings
135
+ .spyderproject
136
+ .spyproject
137
+
138
+ # Rope project settings
139
+ .ropeproject
140
+
141
+ # mkdocs documentation
142
+ /site
143
+
144
+ # mypy
145
+ .mypy_cache/
146
+ .dmypy.json
147
+ dmypy.json
148
+
149
+ # Pyre type checker
150
+ .pyre/
151
+
152
+ # pytype static type analyzer
153
+ .pytype/
154
+
155
+ # Cython debug symbols
156
+ cython_debug/
157
+
158
+ # PyCharm
159
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
162
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163
+ #.idea/
164
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phillip Kuhrt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,342 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-pyoidc-keycloak-extensions
3
+ Version: 0.2.2
4
+ Summary: Keycloak user/group synchronisation, encrypted token storage and token exchange for django-pyoidc
5
+ Project-URL: Homepage, https://github.com/phi1010/django-pyoidc-keycloak-extensions
6
+ Project-URL: Source, https://github.com/phi1010/django-pyoidc-keycloak-extensions
7
+ Project-URL: Issues, https://github.com/phi1010/django-pyoidc-keycloak-extensions/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: django,keycloak,oidc,sso,token-exchange
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Framework :: Django :: 6.0
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.14
24
+ Requires-Dist: django-fernet-encrypted-fields>=0.4.0
25
+ Requires-Dist: django-pyoidc>=1.0.13
26
+ Requires-Dist: django>=5.2
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: urllib3>=2
29
+ Provides-Extra: celery
30
+ Requires-Dist: celery>=5.4; extra == 'celery'
31
+ Provides-Extra: dev
32
+ Requires-Dist: django-stubs>=5.0; extra == 'dev'
33
+ Requires-Dist: mypy>=1.11; extra == 'dev'
34
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
35
+ Requires-Dist: pytest-django>=4.8; extra == 'dev'
36
+ Requires-Dist: pytest>=8; extra == 'dev'
37
+ Requires-Dist: respx>=0.21; extra == 'dev'
38
+ Requires-Dist: ruff>=0.6; extra == 'dev'
39
+ Requires-Dist: testcontainers>=4.8; extra == 'dev'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # django-pyoidc-keycloak-extensions
43
+
44
+ Keycloak user and group synchronisation, encrypted token storage and RFC 8693 token exchange
45
+ for Django projects that authenticate through
46
+ [django-pyoidc](https://pypi.org/project/django-pyoidc/).
47
+
48
+ django-pyoidc handles the OIDC login flow and stops there. This library adds what a project
49
+ needs when Keycloak is the system of record:
50
+
51
+ * **Identity is the Keycloak UUID**, not an email address. Emails change and get reused.
52
+ * **Users stay in step with Keycloak** — renames, disables and deletions arrive through
53
+ event polling, with full reconciliation as the correctness backstop.
54
+ * **Deleted accounts are removed, or anonymised** when local data still references them.
55
+ * **Groups mirror Keycloak**, with temporary manual overrides an admin can grant.
56
+ * **Permissions are never stored locally.** Every `has_perm` goes to your own authorization
57
+ backend (Open Policy Agent, or whatever you use).
58
+ * **Raw tokens are stored encrypted**, refreshed lazily, and exchangeable for another audience.
59
+
60
+ ## Requirements
61
+
62
+ Python 3.14+, Django 5.2+, django-pyoidc 1.0.13+, Keycloak 26.2+ for token exchange.
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ uv pip install django-pyoidc-keycloak-extensions
68
+ ```
69
+
70
+ ## Keycloak setup
71
+
72
+ This library reuses the client you already configured for django-pyoidc — there is no
73
+ separate service account to create. On that client:
74
+
75
+ 1. **Client authentication: on** (it must be confidential).
76
+ 2. **Service accounts roles: on.**
77
+ 3. On the service-account user, assign these `realm-management` roles:
78
+ `view-users`, `query-users`, `query-groups`, `view-events`, `view-realm`.
79
+ 4. In *Realm settings → Sessions/Events*, enable **admin events** and **user events** — event
80
+ polling reads both, and neither is on by default.
81
+ 5. For token exchange: switch on **Standard token exchange** on the client, and make sure the
82
+ user holds a role on the target client so that audience is within your client's scope.
83
+
84
+ > This grants the browser-facing login client read access to the realm's user directory. A
85
+ > leaked client secret therefore exposes more than it would with a separate admin client — an
86
+ > accepted trade-off for a single set of credentials. Split them with
87
+ > `KEYCLOAK["ADMIN_CLIENT_ID"]` / `["ADMIN_CLIENT_SECRET"]` if you would rather not.
88
+
89
+ ## Django setup
90
+
91
+ `AUTH_USER_MODEL` must be set **before the project's first migrate**. Changing it later means
92
+ a manual migration.
93
+
94
+ ```python
95
+ INSTALLED_APPS = [
96
+ ...,
97
+ "django_pyoidc",
98
+ "django_pyoidc_keycloak",
99
+ ]
100
+
101
+ AUTH_USER_MODEL = "keycloak.KeycloakUser"
102
+
103
+ # The first backend resolves the logged-in user from the session on every request; the
104
+ # second decides every permission. ModelBackend must NOT be here: a system check rejects
105
+ # it, because it would answer has_perm() from the database.
106
+ AUTHENTICATION_BACKENDS = [
107
+ "django_pyoidc_keycloak.backends.KeycloakSessionBackend",
108
+ "myproject.authz.OPABackend",
109
+ ]
110
+
111
+ SALT_KEY = env("SALT_KEY") # token encryption; see "Token encryption" below
112
+
113
+ DJANGO_PYOIDC = {
114
+ "sso": {
115
+ "client_id": "django-app",
116
+ "client_secret": env("OIDC_CLIENT_SECRET"),
117
+ "provider_class": "KeycloakProvider",
118
+ "keycloak_base_uri": "https://sso.example.org",
119
+ "keycloak_realm": "myrealm",
120
+ "hook_get_user": "django_pyoidc_keycloak.hooks.get_user",
121
+ "hook_user_login": "django_pyoidc_keycloak.hooks.user_login",
122
+ "hook_user_logout": "django_pyoidc_keycloak.hooks.user_logout",
123
+ "hook_session_logout": "django_pyoidc_keycloak.hooks.session_logout",
124
+ }
125
+ }
126
+
127
+ KEYCLOAK = {
128
+ "OP_NAME": "sso",
129
+ }
130
+ ```
131
+
132
+ ### Your authorization backend
133
+
134
+ Permissions only -- nothing about users:
135
+
136
+ ```python
137
+ class OPABackend:
138
+ def has_perm(self, user_obj, perm, obj=None): ...
139
+ def has_module_perms(self, user_obj, app_label): ...
140
+ def get_all_permissions(self, user_obj, obj=None): # optional; the admin index uses it
141
+ ...
142
+ ```
143
+
144
+ Every configured backend is asked in turn, so your backend does not have to be the one the
145
+ session records. In particular it needs no `get_user`: Django resolves the logged-in user
146
+ by calling `get_user()` on the backend stored in the session, and
147
+ `KeycloakSessionBackend` -- shipped with this library -- is what serves that request. It
148
+ does the primary-key lookup and nothing else, so a deactivated or anonymised account stops
149
+ resolving to a session immediately, and no permission ever comes out of the database.
150
+
151
+ `is_superuser` short-circuits to `True` before your backend is consulted, and `is_staff`
152
+ gates admin access.
153
+
154
+ `KeycloakSessionBackend` must appear in `AUTHENTICATION_BACKENDS` (system check
155
+ `keycloak.E004`): `django.contrib.auth` ignores a session backend that is not listed there
156
+ and silently falls back to `AnonymousUser` on every request. You may subclass it; the
157
+ library discovers it by type.
158
+
159
+ ### Permissions your policy will be asked about
160
+
161
+ All of the form `<app_label>.<verb>_<model_name>`, so a swapped model changes both halves:
162
+
163
+ | Verb | Example | Meaning |
164
+ | --- | --- | --- |
165
+ | `view` / `add` / `change` / `delete` | `keycloak.change_keycloakuser` | Django's four, unchanged. |
166
+ | `sync` | `keycloak.sync_keycloakuser` | Pull this record from Keycloak now. |
167
+
168
+ `sync` is this library's own verb, and it is **independent of `change`** in both directions.
169
+ It gates the "Sync now" button on the user page and the two bulk actions on the changelist.
170
+ Synchronising is neither reading nor editing: it pulls the record from the realm and, when
171
+ the account has gone, deletes or anonymises it locally. So a policy can grant `sync`
172
+ without `change` -- an operator who may repair drift but not hand-edit fields -- or `change`
173
+ without `sync`, for someone who administers local-only accounts but must not trigger Admin
174
+ API traffic. Without the verb the button is not rendered and the actions do not appear in
175
+ the changelist dropdown.
176
+
177
+ No `Permission` row is created for `sync` (none is created for anything -- see
178
+ `CREATE_DJANGO_PERMISSIONS`); the string is simply what your backend is asked about.
179
+
180
+ ## Logging
181
+
182
+ Every module logs under its own name below `django_pyoidc_keycloak`, so the whole library
183
+ can be turned up at once:
184
+
185
+ ```python
186
+ LOGGING = {
187
+ "version": 1,
188
+ "loggers": {
189
+ "django_pyoidc_keycloak": {"level": "DEBUG", "handlers": ["console"]},
190
+ },
191
+ }
192
+ ```
193
+
194
+ | Level | What you get |
195
+ | --- | --- |
196
+ | `INFO` | Synchronisation runs and their counts, users deleted or anonymised, event cursors advancing, token sets dropped or purged. |
197
+ | `DEBUG` | Every decision behind those: the poll window, why an event was skipped, which fields a representation changed, each Admin API request and status, refresh-lock contention. |
198
+ | `WARNING` | Something was survivable but did not happen -- a role read that failed, tokens that could not be stored. |
199
+
200
+ **Nothing logged is a secret or personal data, at any level.** Tokens, client secrets and
201
+ passwords never reach a log record: HTTP response bodies are passed through `scrub()` before
202
+ they are rendered, and request payloads are never logged at all. Accounts are identified by
203
+ `keycloak_id` and local primary key, never by username, email or name; a Keycloak
204
+ representation is logged as its list of *keys*, and a change as its list of *field names*.
205
+ Group paths are logged, since they are realm configuration rather than user data.
206
+
207
+ `tests/test_logging.py` enforces this with sentinel values, so it stays true.
208
+
209
+ ## Scheduling
210
+
211
+ ```cron
212
+ */2 * * * * manage.py keycloak_sync_events # incremental
213
+ 17 * * * * manage.py keycloak_reconcile # the correctness backstop
214
+ 30 3 * * * manage.py keycloak_purge_tokens # expired tokens and memberships
215
+ ```
216
+
217
+ Both are needed. Admin events only cover changes made through the Admin API or console;
218
+ self-service edits land in the user event stream, LDAP-federated changes produce no events at
219
+ all, and events expire. `keycloak_reconcile` is what guarantees convergence.
220
+
221
+ With Celery installed, `django_pyoidc_keycloak.tasks` offers the same operations as tasks, and
222
+ the admin's bulk actions enqueue instead of blocking the request.
223
+
224
+ ## Token encryption
225
+
226
+ Tokens are stored in Fernet-encrypted columns via `django-fernet-encrypted-fields`, which
227
+ derives its key from `SECRET_KEY` **and** `SALT_KEY`. Set `SALT_KEY` to a long random value
228
+ kept out of version control. Rotating `SECRET_KEY` without listing the old value in
229
+ `SECRET_KEY_FALLBACKS` makes existing tokens unreadable — they are session-scoped and
230
+ disposable, so the library logs a warning and treats them as absent rather than erroring.
231
+
232
+ Tokens are at rest in exactly one place: those columns. Never the cache, the session, a log
233
+ line, or an admin page.
234
+
235
+ ## Using the tokens
236
+
237
+ ```python
238
+ from django_pyoidc_keycloak.tokens.refresh import get_access_token_for_user
239
+ from django_pyoidc_keycloak.tokens.exchange import exchange_token
240
+
241
+ token = get_access_token_for_user(request.user) # refreshed if near expiry
242
+ downstream = exchange_token(request.user, audience="reports-api")
243
+ ```
244
+
245
+ Refresh is lazy and on demand, never scheduled: refreshing on a timer resets Keycloak's SSO
246
+ Session Idle clock (defeating idle timeout), is still capped by SSO Session Max, and races the
247
+ user's own browser refresh, which trips reuse detection when rotation is on. For work while
248
+ the user is away, set `KEYCLOAK["REQUEST_OFFLINE_ACCESS"] = True` to obtain an offline token,
249
+ which is exempt from SSO Session Max.
250
+
251
+ ## Settings
252
+
253
+ | Setting | Default | Meaning |
254
+ | --- | --- | --- |
255
+ | `OP_NAME` | auto | Which `DJANGO_PYOIDC` provider to use; required if several are configured. |
256
+ | `SERVER_URL` / `REALM` | from django-pyoidc | Override the derived Keycloak location. |
257
+ | `ADMIN_CLIENT_ID` / `ADMIN_CLIENT_SECRET` | from django-pyoidc | Use a separate admin client. |
258
+ | `IMPORT_ALL_USERS` | `False` | Create local users for accounts that never logged in. |
259
+ | `SYNC_ON_LOGIN` | `True` | Refresh the user from claims at each login. |
260
+ | `SYNC_GROUPS` | `True` | Mirror group membership. |
261
+ | `USERNAME_STRATEGY` | built-in | Dotted path to your own username derivation. |
262
+ | `STAFF_ROLES` / `SUPERUSER_ROLES` | `[]` | Realm roles that map to `is_staff` / `is_superuser`. |
263
+ | `CREATE_DJANGO_PERMISSIONS` | `False` | Let Django populate `auth_permission` again. |
264
+ | `STORE_TOKENS` | `True` | Store raw tokens at login. |
265
+ | `REQUEST_OFFLINE_ACCESS` | `False` | Request `offline_access` scope. |
266
+ | `TOKEN_EXCHANGE_ENABLED` | `False` | Enables the token-exchange system check. |
267
+ | `ADMIN_BULK_INLINE_LIMIT` | `50` | Cap on synchronising inline from the admin without Celery. |
268
+ | `EVENT_OVERLAP_SECONDS` | `300` | How far back each event poll re-reads. |
269
+
270
+ ## Security notes
271
+
272
+ **The Django cache must be trusted storage.** django-pyoidc stores its pyoidc state in the
273
+ cache and reads it back with `jsonpickle.decode` (upstream marks this `noqa: S301`), so
274
+ anyone who can write to that cache can execute code in your process on the next decode. Do
275
+ not point `CACHES["default"]` at a Redis or memcached instance shared with less-trusted
276
+ components, and keep it authenticated and network-isolated.
277
+
278
+ **Group mappers must derive from actual group membership.** At login, membership is read from
279
+ the `groups` claim when present. This library only ever grants membership in groups Keycloak
280
+ owns — a locally created group can never be reached through a claim — but if you configure
281
+ the mapper over a user-editable attribute, a user controls their own claim content.
282
+
283
+ **Token encryption uses PBKDF2-SHA256 at 100 000 iterations**, which is what
284
+ `django-fernet-encrypted-fields` does and is below OWASP's current 600k guidance. Session
285
+ tokens are short-lived, so this is minor; weigh it if you enable `REQUEST_OFFLINE_ACCESS`,
286
+ since offline tokens live much longer.
287
+
288
+ **`django-pyoidc` has no upper version bound.** It holds the actual OIDC request paths, so
289
+ pin it in your own project and read its release notes before upgrading.
290
+
291
+ ## Notes on behaviour worth knowing
292
+
293
+ * **Local-only users are never touched.** A user with `keycloak_id = NULL` (your bootstrap
294
+ superuser, for instance) survives every reconciliation.
295
+ * **Anonymisation keeps a tombstone.** `keycloak_id` is retained so a deleted Keycloak account
296
+ is never re-imported as a fresh user.
297
+ * **Service-account users are not imported.** Keycloak excludes them from `GET /users`, so
298
+ reconciliation never sees them; one is only created if it actually logs in.
299
+ * **Inactive users have no permissions.** `has_perm` returns `False` for `is_active=False`
300
+ before any backend is consulted, so disabling an account in Keycloak revokes access as soon
301
+ as sync notices, without waiting for your policy engine.
302
+ * **Two empty tables remain.** `django.contrib.auth` cannot be removed from `INSTALLED_APPS`,
303
+ so `auth_permission` and `auth_group` exist. This library never reads or writes them, and
304
+ permission creation is disconnected so they stay empty.
305
+
306
+ ## Development
307
+
308
+ ```bash
309
+ uv sync --extra dev --extra celery
310
+ uv run pytest # unit tests
311
+ uv run pytest -m integration # against a real Keycloak in Podman
312
+ ```
313
+
314
+ The integration suite starts `quay.io/keycloak/keycloak:26.4` through Podman's socket, imports
315
+ a realm, and drives the full cycle: reconcile, mutate, poll, delete, refresh, exchange. It
316
+ skips itself if Podman is not installed.
317
+
318
+ ## Releasing
319
+
320
+ Publishing runs on GitHub Actions (`.github/workflows/publish.yml`) using PyPI **Trusted
321
+ Publishing**, so there is no API token in repository secrets.
322
+
323
+ One-time setup:
324
+
325
+ 1. On PyPI, add a *pending publisher* under the project's *Publishing* settings — owner
326
+ `phi1010`, repository `django-pyoidc-keycloak-extensions`, workflow `publish.yml`,
327
+ environment `pypi`. Repeat on TestPyPI with environment `testpypi` if you want a dry run.
328
+ 2. In the repository settings, create the `pypi` (and optionally `testpypi`) environments.
329
+ Adding required reviewers there puts a manual gate between the release and the upload.
330
+
331
+ To release:
332
+
333
+ 1. Bump `version` in `pyproject.toml` and commit.
334
+ 2. Tag and push: `git tag v0.2.0 && git push --tags`.
335
+ 3. Publish a GitHub Release for that tag.
336
+
337
+ The workflow re-runs lint, the unit tests and the migration check, refuses to publish if the
338
+ tag and `pyproject.toml` disagree on the version, verifies the wheel actually contains the
339
+ admin templates and migrations, uploads to PyPI, and attaches the artefacts to the release.
340
+
341
+ `workflow_dispatch` publishes to TestPyPI by default, for rehearsing a release without
342
+ burning a version number — PyPI uploads are immutable and a version can never be reused.