bazis-users 2.3.2__tar.gz → 2.4.0__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 (100) hide show
  1. {bazis_users-2.3.2 → bazis_users-2.4.0}/PKG-INFO +2 -2
  2. bazis_users-2.4.0/bazis/contrib/users/AGENTS.md +61 -0
  3. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/_version.py +3 -3
  4. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/apps.py +4 -0
  5. bazis_users-2.4.0/bazis/contrib/users/bazis_manifest.toml +45 -0
  6. bazis_users-2.4.0/bazis/contrib/users/checks.py +48 -0
  7. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/routes.py +47 -0
  8. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis_users.egg-info/PKG-INFO +2 -2
  9. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis_users.egg-info/SOURCES.txt +6 -0
  10. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis_users.egg-info/requires.txt +1 -1
  11. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis_users.egg-info/scm_file_list.json +6 -0
  12. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis_users.egg-info/scm_version.json +2 -2
  13. bazis_users-2.4.0/docs/releases/2.4.0.md +20 -0
  14. {bazis_users-2.3.2 → bazis_users-2.4.0}/pyproject.toml +1 -1
  15. bazis_users-2.4.0/tests/test_manifest.py +35 -0
  16. bazis_users-2.4.0/tests/test_user_access.py +113 -0
  17. {bazis_users-2.3.2 → bazis_users-2.4.0}/.dockerignore +0 -0
  18. {bazis_users-2.3.2 → bazis_users-2.4.0}/.github/workflows/build-publish.yml +0 -0
  19. {bazis_users-2.3.2 → bazis_users-2.4.0}/.github/workflows/release.yml +0 -0
  20. {bazis_users-2.3.2 → bazis_users-2.4.0}/.github/workflows/tests.yml +0 -0
  21. {bazis_users-2.3.2 → bazis_users-2.4.0}/.github/workflows/update-licenses.yml +0 -0
  22. {bazis_users-2.3.2 → bazis_users-2.4.0}/.gitignore +0 -0
  23. {bazis_users-2.3.2 → bazis_users-2.4.0}/CLAUDE.md +0 -0
  24. {bazis_users-2.3.2 → bazis_users-2.4.0}/README.md +0 -0
  25. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/__init__.py +0 -0
  26. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/admin_abstract.py +0 -0
  27. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/conf.py +0 -0
  28. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/locale/en/LC_MESSAGES/django.mo +0 -0
  29. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/locale/en/LC_MESSAGES/django.po +0 -0
  30. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/locale/ru/LC_MESSAGES/django.mo +0 -0
  31. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/locale/ru/LC_MESSAGES/django.po +0 -0
  32. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/middleware.py +0 -0
  33. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/migrations/0001_initial.py +0 -0
  34. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/migrations/0002_user_dt_first_login.py +0 -0
  35. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/migrations/__init__.py +0 -0
  36. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/models.py +0 -0
  37. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/models_abstract.py +0 -0
  38. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/router.py +0 -0
  39. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/routes_abstract.py +0 -0
  40. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/schemas.py +0 -0
  41. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis/contrib/users/service.py +0 -0
  42. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis_users.egg-info/dependency_links.txt +0 -0
  43. {bazis_users-2.3.2 → bazis_users-2.4.0}/bazis_users.egg-info/top_level.txt +0 -0
  44. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/Makefile +0 -0
  45. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/README.md +0 -0
  46. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/_static/pydata-custom.css +0 -0
  47. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/_static/readthedocs-custom.css +0 -0
  48. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/_templates/custom-class-template.rst +0 -0
  49. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/_templates/custom-module-template.rst +0 -0
  50. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/conf.py +0 -0
  51. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/index.rst +0 -0
  52. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/make.bat +0 -0
  53. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/manage.py +0 -0
  54. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/project.env +0 -0
  55. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/releases/2.3.0.md +0 -0
  56. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/releases/2.3.1.md +0 -0
  57. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/releases/2.3.2.md +0 -0
  58. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/requirements.txt +0 -0
  59. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/settings.py +0 -0
  60. {bazis_users-2.3.2 → bazis_users-2.4.0}/docs/structure.rst +0 -0
  61. {bazis_users-2.3.2 → bazis_users-2.4.0}/pytest.ini +0 -0
  62. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/deploy/admin.sh +0 -0
  63. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/deploy/app.sh +0 -0
  64. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/deploy/config/admin.py +0 -0
  65. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/deploy/config/app.py +0 -0
  66. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/deploy/db.sh +0 -0
  67. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/deploy/init.sh +0 -0
  68. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/entity/__init__.py +0 -0
  69. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/entity/admin.py +0 -0
  70. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/entity/migrations/0001_initial.py +0 -0
  71. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/entity/migrations/0002_alter_childentity_dt_created_and_more.py +0 -0
  72. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/entity/migrations/__init__.py +0 -0
  73. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/entity/models.py +0 -0
  74. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/entity/router.py +0 -0
  75. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/entity/routes.py +0 -0
  76. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/manage.py +0 -0
  77. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/project.env +0 -0
  78. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/pyproject.toml +0 -0
  79. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/sample/__init__.py +0 -0
  80. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/sample/main.py +0 -0
  81. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/sample/router.py +0 -0
  82. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/sample/settings.py +0 -0
  83. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/sample/urls.py +0 -0
  84. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/sample/wsgi.py +0 -0
  85. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/users/__init__.py +0 -0
  86. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/users/admin.py +0 -0
  87. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/users/apps.py +0 -0
  88. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/users/migrations/0001_initial.py +0 -0
  89. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/users/migrations/0002_user_dt_first_login.py +0 -0
  90. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/users/migrations/__init__.py +0 -0
  91. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/users/models.py +0 -0
  92. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/users/router.py +0 -0
  93. {bazis_users-2.3.2 → bazis_users-2.4.0}/sample/uv.lock +0 -0
  94. {bazis_users-2.3.2 → bazis_users-2.4.0}/setup.cfg +0 -0
  95. {bazis_users-2.3.2 → bazis_users-2.4.0}/tests/__init__.py +0 -0
  96. {bazis_users-2.3.2 → bazis_users-2.4.0}/tests/conftest.py +0 -0
  97. {bazis_users-2.3.2 → bazis_users-2.4.0}/tests/factories.py +0 -0
  98. {bazis_users-2.3.2 → bazis_users-2.4.0}/tests/test_protected.py +0 -0
  99. {bazis_users-2.3.2 → bazis_users-2.4.0}/tests/test_tokens.py +0 -0
  100. {bazis_users-2.3.2 → bazis_users-2.4.0}/tests/test_users.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazis-users
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: User management module for Bazis framework.
5
5
  Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
6
6
  Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
@@ -17,7 +17,7 @@ Classifier: Framework :: Django
17
17
  Classifier: Framework :: FastAPI
18
18
  Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
- Requires-Dist: bazis>=2.3.1
20
+ Requires-Dist: bazis>=2.4.0
21
21
  Requires-Dist: python-multipart>=0.0.18
22
22
  Requires-Dist: PyJWT>=2.10
23
23
  Provides-Extra: test
@@ -0,0 +1,61 @@
1
+ # bazis-users — guide for AI agents
2
+
3
+ Users and authentication by session JWT for Bazis: the user model, the token endpoint,
4
+ and the route base classes that know the current user. Use it whenever an API has users;
5
+ bazis-permit (permissions) and bazis-authing (login flows) build on it.
6
+
7
+ ## Setup
8
+
9
+ 1. A project app with the user model (the package does not create the table itself):
10
+
11
+ ```python
12
+ # users/models.py
13
+ from bazis.contrib.users.models_abstract import AnonymousUserAbstract, UserAbstract
14
+ from bazis.core.models_abstract import JsonApiMixin, UuidMixin
15
+
16
+ class User(JsonApiMixin, UuidMixin, UserAbstract):
17
+ pass
18
+
19
+ class AnonymousUser(AnonymousUserAbstract):
20
+ pass
21
+
22
+ # users/apps.py
23
+ from bazis.contrib.users.apps import UsersConfig as UsersConfigBase
24
+
25
+ class UsersConfig(UsersConfigBase):
26
+ name = 'users'
27
+ ```
28
+
29
+ 2. `BS_INSTALLED_APPS='["users", ...]'`, `BS_AUTH_USER_MODEL=users.User`,
30
+ `BS_AUTH_ANONYMOUS_USER_MODEL=users.models.AnonymousUser`.
31
+ 3. Optionally register the user routes: `router.register('bazis.contrib.users.router')`
32
+ (`/user/`). With them a user sees and changes only himself, staff create, list and
33
+ delete users, and only a superuser changes `is_staff`, `is_superuser`, `is_active`.
34
+ Projects with bazis-permit protect users with a `PermitRouteBase` route instead.
35
+
36
+ ## Authentication
37
+
38
+ - `POST BAZIS_OPENAPI_TOKEN_URL` (default `/api/openapi-token/`, OAuth2 password form)
39
+ returns `{"access_token": ..., "token_type": "bearer"}`; Swagger uses it.
40
+ - The token is read from `Authorization: Bearer`, or the query parameter or the cookie
41
+ named `BAZIS_AUTH_COOKIE_NAME` (default `bazis_auth`). It is an HMAC JWT signed with
42
+ `SECRET_KEY` with `sub` (username), `iat` and `exp` (`BAZIS_JWT_SESSION_LIFETIME`
43
+ seconds). A token without
44
+ `exp` is anonymous; an expired or invalid token is 401.
45
+ - `user.jwt_build()` builds a token (e.g. after a custom login).
46
+
47
+ ## Routes
48
+
49
+ - `UserRouteBase` (`bazis.contrib.users.routes_abstract`): `self.inject.user` is the user
50
+ or the anonymous user; the filter context gets `_user`. Base class for routes that need
51
+ the user (bazis-permit routes extend it).
52
+ - `UserRequiredRouteBase`: the same, for routes that require authentication.
53
+ - FastAPI dependencies in `bazis.contrib.users.service`: `get_user_optional`,
54
+ `get_user_required`, `get_token_data`.
55
+
56
+ ## Rules
57
+
58
+ - A user must never be able to change his own groups, permissions or staff flags: keep
59
+ those fields out of the update schemas of custom user routes, or check them in
60
+ `hook_after_update` as `UserRouteSet` does.
61
+ - Check `bazis_doctor` after changing the user model (`users.E001`).
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.3.2'
22
- __version_tuple__ = version_tuple = (2, 3, 2)
21
+ __version__ = version = '2.4.0'
22
+ __version_tuple__ = version_tuple = (2, 4, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gfa5e68bf1'
24
+ __commit_id__ = commit_id = 'gd67874105'
@@ -26,3 +26,7 @@ class UsersConfig(BaseConfig):
26
26
 
27
27
  name = 'bazis.contrib.users'
28
28
  verbose_name = _('Users')
29
+
30
+ def ready(self):
31
+ super().ready()
32
+ from . import checks # noqa: F401 registers the system checks
@@ -0,0 +1,45 @@
1
+ # What the package is for and how to use it, for tools and AI agents (see AGENTS.md).
2
+ # `import` paths and `check` ids are verified by tests/test_manifest.py.
3
+
4
+ [package]
5
+ name = "bazis-users"
6
+ summary = "users, session JWT authentication and routes that know the current user"
7
+ solves = [
8
+ "user accounts and login by username and password",
9
+ "authentication by JWT (header, query parameter or cookie)",
10
+ "routes that need the current user or require authentication",
11
+ ]
12
+ requires = ["bazis"]
13
+ pairs_well = ["bazis-permit", "bazis-authing", "bazis-author"]
14
+
15
+ [[extension_points]]
16
+ name = "UserAbstract"
17
+ import = "bazis.contrib.users.models_abstract.UserAbstract"
18
+ use_when = "the user model of the project (with JsonApiMixin, UuidMixin)"
19
+
20
+ [[extension_points]]
21
+ name = "UsersConfig"
22
+ import = "bazis.contrib.users.apps.UsersConfig"
23
+ use_when = "the AppConfig of the project app that holds the user model"
24
+
25
+ [[extension_points]]
26
+ name = "UserRouteBase"
27
+ import = "bazis.contrib.users.routes_abstract.UserRouteBase"
28
+ use_when = "a route that needs the current user (self.inject.user)"
29
+
30
+ [[extension_points]]
31
+ name = "UserRequiredRouteBase"
32
+ import = "bazis.contrib.users.routes_abstract.UserRequiredRouteBase"
33
+ use_when = "a route that requires authentication"
34
+
35
+ [[extension_points]]
36
+ name = "get_user_required"
37
+ import = "bazis.contrib.users.service.get_user_required"
38
+ use_when = "a FastAPI dependency for the authenticated user in a custom endpoint"
39
+
40
+ [[pitfalls]]
41
+ text = "the user model must inherit UserAbstract and be set in BS_AUTH_USER_MODEL"
42
+ check = "users.E001"
43
+
44
+ [[pitfalls]]
45
+ text = "groups, permissions and is_staff must not be writable by the user himself"
@@ -0,0 +1,48 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Django system checks of bazis-users (see `manage.py bazis_doctor`).
17
+ """
18
+
19
+ from django.contrib.auth import get_user_model
20
+ from django.core.checks import Error, register
21
+ from django.core.exceptions import ImproperlyConfigured
22
+
23
+
24
+ @register()
25
+ def check_user_model(app_configs, **kwargs):
26
+ """
27
+ The session tokens and the user routes need the user model of bazis-users.
28
+ """
29
+ from .models_abstract import UserAbstract
30
+
31
+ try:
32
+ user_model = get_user_model()
33
+ except ImproperlyConfigured as err:
34
+ return [
35
+ Error(str(err), hint='Set BS_AUTH_USER_MODEL to an installed model.', id='users.E001')
36
+ ]
37
+ if not issubclass(user_model, UserAbstract):
38
+ return [
39
+ Error(
40
+ f'The user model {user_model._meta.label} does not inherit UserAbstract.',
41
+ hint=(
42
+ 'Inherit the user model from bazis.contrib.users.models_abstract.UserAbstract '
43
+ 'and set BS_AUTH_USER_MODEL to it.'
44
+ ),
45
+ id='users.E001',
46
+ )
47
+ ]
48
+ return []
@@ -23,6 +23,7 @@ from fastapi.security import OAuth2PasswordRequestForm
23
23
  from starlette.status import HTTP_401_UNAUTHORIZED
24
24
 
25
25
  from bazis.core.app import app
26
+ from bazis.core.errors import JsonApi403Exception
26
27
  from bazis.core.routes_abstract.initial import inject_make
27
28
  from bazis.core.routes_abstract.jsonapi import JsonapiRouteBase
28
29
  from bazis.core.schemas import CrudApiAction, SchemaFields, SchemaInclusion, SchemaInclusions
@@ -81,6 +82,52 @@ class UserRouteSet(JsonapiRouteBase):
81
82
  ),
82
83
  }
83
84
 
85
+ #: the fields only a superuser can set
86
+ privileged_fields = ('is_staff', 'is_superuser', 'is_active')
87
+
88
+ def get_queryset(self):
89
+ """
90
+ A user who is not staff sees and changes only himself.
91
+ """
92
+ queryset = super().get_queryset()
93
+ if not self.inject.user.is_staff:
94
+ queryset = queryset.filter(pk=self.inject.user.pk)
95
+ return queryset
96
+
97
+ def check_privileged_fields(self, item, before: dict):
98
+ if self.inject.user.is_superuser:
99
+ return
100
+ for field in self.privileged_fields:
101
+ if getattr(item, field) != before[field]:
102
+ raise JsonApi403Exception(detail=str(_('Only a superuser can change %s')) % field)
103
+
104
+ def hook_before_create(self, item):
105
+ if not self.inject.user.is_staff:
106
+ raise JsonApi403Exception()
107
+ super().hook_before_create(item)
108
+
109
+ def hook_after_create(self, item):
110
+ # inside the transaction: the error rolls the creation back
111
+ super().hook_after_create(item)
112
+ self.check_privileged_fields(
113
+ item, {'is_staff': False, 'is_superuser': False, 'is_active': True}
114
+ )
115
+
116
+ def hook_before_update(self, item):
117
+ super().hook_before_update(item)
118
+ # the data is not applied yet
119
+ self._privileged_before = {field: getattr(item, field) for field in self.privileged_fields}
120
+
121
+ def hook_after_update(self, item):
122
+ # inside the transaction: the error rolls the change back
123
+ super().hook_after_update(item)
124
+ self.check_privileged_fields(item, self._privileged_before)
125
+
126
+ def destroy(self, item_id: str):
127
+ if not self.inject.user.is_staff:
128
+ raise JsonApi403Exception()
129
+ return super().destroy(item_id)
130
+
84
131
  inclusions = {
85
132
  CrudApiAction.RETRIEVE: SchemaInclusions(
86
133
  origin={
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazis-users
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: User management module for Bazis framework.
5
5
  Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
6
6
  Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
@@ -17,7 +17,7 @@ Classifier: Framework :: Django
17
17
  Classifier: Framework :: FastAPI
18
18
  Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
- Requires-Dist: bazis>=2.3.1
20
+ Requires-Dist: bazis>=2.4.0
21
21
  Requires-Dist: python-multipart>=0.0.18
22
22
  Requires-Dist: PyJWT>=2.10
23
23
  Provides-Extra: test
@@ -8,10 +8,13 @@ pytest.ini
8
8
  .github/workflows/release.yml
9
9
  .github/workflows/tests.yml
10
10
  .github/workflows/update-licenses.yml
11
+ bazis/contrib/users/AGENTS.md
11
12
  bazis/contrib/users/__init__.py
12
13
  bazis/contrib/users/_version.py
13
14
  bazis/contrib/users/admin_abstract.py
14
15
  bazis/contrib/users/apps.py
16
+ bazis/contrib/users/bazis_manifest.toml
17
+ bazis/contrib/users/checks.py
15
18
  bazis/contrib/users/conf.py
16
19
  bazis/contrib/users/middleware.py
17
20
  bazis/contrib/users/models.py
@@ -52,6 +55,7 @@ docs/_templates/custom-module-template.rst
52
55
  docs/releases/2.3.0.md
53
56
  docs/releases/2.3.1.md
54
57
  docs/releases/2.3.2.md
58
+ docs/releases/2.4.0.md
55
59
  sample/manage.py
56
60
  sample/project.env
57
61
  sample/pyproject.toml
@@ -87,6 +91,8 @@ sample/users/migrations/__init__.py
87
91
  tests/__init__.py
88
92
  tests/conftest.py
89
93
  tests/factories.py
94
+ tests/test_manifest.py
90
95
  tests/test_protected.py
91
96
  tests/test_tokens.py
97
+ tests/test_user_access.py
92
98
  tests/test_users.py
@@ -1,4 +1,4 @@
1
- bazis>=2.3.1
1
+ bazis>=2.4.0
2
2
  python-multipart>=0.0.18
3
3
  PyJWT>=2.10
4
4
 
@@ -8,9 +8,12 @@
8
8
  ".gitignore",
9
9
  "CLAUDE.md",
10
10
  "README.md",
11
+ "bazis/contrib/users/AGENTS.md",
11
12
  "bazis/contrib/users/__init__.py",
12
13
  "bazis/contrib/users/admin_abstract.py",
13
14
  "bazis/contrib/users/apps.py",
15
+ "bazis/contrib/users/bazis_manifest.toml",
16
+ "bazis/contrib/users/checks.py",
14
17
  "bazis/contrib/users/conf.py",
15
18
  "bazis/contrib/users/locale/en/LC_MESSAGES/django.mo",
16
19
  "bazis/contrib/users/locale/en/LC_MESSAGES/django.po",
@@ -41,6 +44,7 @@
41
44
  "docs/releases/2.3.0.md",
42
45
  "docs/releases/2.3.1.md",
43
46
  "docs/releases/2.3.2.md",
47
+ "docs/releases/2.4.0.md",
44
48
  "docs/requirements.txt",
45
49
  "docs/settings.py",
46
50
  "docs/structure.rst",
@@ -81,8 +85,10 @@
81
85
  "tests/__init__.py",
82
86
  "tests/conftest.py",
83
87
  "tests/factories.py",
88
+ "tests/test_manifest.py",
84
89
  "tests/test_protected.py",
85
90
  "tests/test_tokens.py",
91
+ "tests/test_user_access.py",
86
92
  "tests/test_users.py"
87
93
  ]
88
94
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "2.3.2",
2
+ "tag": "2.4.0",
3
3
  "distance": 0,
4
- "node": "gfa5e68bf11f081b876fbfebf90cdac9a6b124dcb",
4
+ "node": "gd67874105fc4269a9dafbb50167cb91ed2720884",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-01"
@@ -0,0 +1,20 @@
1
+ # bazis-users 2.4.0
2
+
3
+ Requires Bazis 2.4.
4
+
5
+ ## Security
6
+
7
+ - **Any authenticated user could take over any account through the user routes**
8
+ (`bazis.contrib.users.router`, `UserRouteSet`): list all users, change any user,
9
+ including his password (`raw_password`) and `is_staff` / `is_superuser`, and delete
10
+ users. Now a user who is not staff sees and changes only himself; only staff create and
11
+ delete users; only a superuser changes `is_staff`, `is_superuser` and `is_active`
12
+ (403 otherwise). Projects that let users manage other users through these routes must
13
+ give them staff status.
14
+
15
+ ## Tools and AI agents
16
+
17
+ - The package ships `AGENTS.md` (how to set up users and authentication) and
18
+ `bazis_manifest.toml` for tools and AI agents (see `docs/agents.md` of Bazis).
19
+ - New system check `users.E001`: the user model does not inherit `UserAbstract`
20
+ (`manage.py bazis_doctor`).
@@ -33,7 +33,7 @@ classifiers = [
33
33
  "Framework :: FastAPI",
34
34
  ]
35
35
  dependencies = [
36
- "bazis>=2.3.1",
36
+ "bazis>=2.4.0",
37
37
  "python-multipart>=0.0.18",
38
38
  "PyJWT>=2.10",
39
39
  ]
@@ -0,0 +1,35 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from bazis.contrib.users.checks import check_user_model
16
+ from bazis.core.introspect import packages, validate_manifest
17
+
18
+
19
+ def test_manifest_is_valid():
20
+ assert validate_manifest('bazis.contrib.users') == []
21
+
22
+
23
+ def test_manifest_is_found():
24
+ users = {it['name']: it for it in packages()}['bazis-users']
25
+ assert users['module'] == 'bazis.contrib.users'
26
+ assert users['manifest']['package']['name'] == 'bazis-users'
27
+
28
+
29
+ def test_user_model_check(monkeypatch):
30
+ assert check_user_model(None) == []
31
+
32
+ from django.contrib.auth.models import Group
33
+
34
+ monkeypatch.setattr('bazis.contrib.users.checks.get_user_model', lambda: Group)
35
+ assert [it.id for it in check_user_model(None)] == ['users.E001']
@@ -0,0 +1,113 @@
1
+ # Copyright 2026 EcoFuture Technology Services LLC and contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import pytest
16
+ from bazis_test_utils.utils import get_api_client
17
+
18
+ from bazis.contrib.users import get_user_model
19
+
20
+
21
+ User = get_user_model()
22
+
23
+
24
+ def _patch(user, **attributes) -> dict:
25
+ return {
26
+ 'data': {
27
+ 'id': str(user.id),
28
+ 'type': 'users.user',
29
+ 'bs:action': 'change',
30
+ 'attributes': attributes,
31
+ }
32
+ }
33
+
34
+
35
+ @pytest.fixture
36
+ def people():
37
+ superuser = User.objects.create_superuser('root', password='weak_password_1')
38
+ staff = User.objects.create_user('staff', password='weak_password_2', is_staff=True)
39
+ user = User.objects.create_user('user', password='weak_password_3')
40
+ other = User.objects.create_user('other', password='weak_password_4')
41
+ return superuser, staff, user, other
42
+
43
+
44
+ @pytest.mark.django_db(transaction=True)
45
+ def test_user_sees_and_changes_only_himself(sample_app, people):
46
+ """
47
+ Any authenticated user could list, change (including the password) and delete any
48
+ user through the user routes.
49
+ """
50
+ _, _, user, other = people
51
+ client = get_api_client(sample_app, user.jwt_build())
52
+
53
+ response = client.get('/api/v1/users/user/')
54
+ assert [it['id'] for it in response.json()['data']] == [str(user.id)]
55
+ assert client.get(f'/api/v1/users/user/{other.id}/').status_code == 404
56
+
57
+ response = client.patch(
58
+ f'/api/v1/users/user/{other.id}/', json_data=_patch(other, raw_password='hijacked1')
59
+ )
60
+ assert response.status_code == 404
61
+ other.refresh_from_db()
62
+ assert other.check_password('weak_password_4')
63
+
64
+ response = client.patch(f'/api/v1/users/user/{user.id}/', json_data=_patch(user, first_name='Me'))
65
+ assert response.status_code == 200
66
+
67
+ assert client.delete(f'/api/v1/users/user/{user.id}/').status_code == 403
68
+ assert User.objects.filter(id=user.id).exists()
69
+
70
+
71
+ @pytest.mark.django_db(transaction=True)
72
+ @pytest.mark.parametrize('field', ['is_staff', 'is_superuser', 'is_active'])
73
+ def test_only_superuser_changes_privileges(sample_app, people, field):
74
+ superuser, staff, user, _ = people
75
+ value = field != 'is_active'
76
+
77
+ for actor in (user, staff):
78
+ response = get_api_client(sample_app, actor.jwt_build()).patch(
79
+ f'/api/v1/users/user/{user.id}/', json_data=_patch(user, **{field: value})
80
+ )
81
+ assert response.status_code == 403
82
+ user.refresh_from_db()
83
+ assert getattr(user, field) is not value
84
+
85
+ response = get_api_client(sample_app, superuser.jwt_build()).patch(
86
+ f'/api/v1/users/user/{user.id}/', json_data=_patch(user, **{field: value})
87
+ )
88
+ assert response.status_code == 200
89
+ user.refresh_from_db()
90
+ assert getattr(user, field) is value
91
+
92
+
93
+ @pytest.mark.django_db(transaction=True)
94
+ def test_only_staff_creates_users(sample_app, people):
95
+ _, staff, user, _ = people
96
+ payload = {
97
+ 'data': {
98
+ 'type': 'users.user',
99
+ 'bs:action': 'add',
100
+ 'attributes': {'username': 'new', 'raw_password': 'weak_password_5'},
101
+ }
102
+ }
103
+ response = get_api_client(sample_app, user.jwt_build()).post('/api/v1/users/user/', json_data=payload)
104
+ assert response.status_code == 403
105
+
106
+ payload['data']['attributes']['is_superuser'] = True
107
+ response = get_api_client(sample_app, staff.jwt_build()).post('/api/v1/users/user/', json_data=payload)
108
+ assert response.status_code == 403
109
+ assert not User.objects.filter(username='new').exists()
110
+
111
+ del payload['data']['attributes']['is_superuser']
112
+ response = get_api_client(sample_app, staff.jwt_build()).post('/api/v1/users/user/', json_data=payload)
113
+ assert response.status_code == 201
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes