oxutils 0.1.8__tar.gz → 0.1.10__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.
- {oxutils-0.1.8 → oxutils-0.1.10}/PKG-INFO +1 -1
- {oxutils-0.1.8 → oxutils-0.1.10}/pyproject.toml +1 -1
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/__init__.py +2 -1
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/constants.py +4 -0
- oxutils-0.1.10/src/oxutils/jwt/auth.py +204 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/jwt/models.py +13 -0
- oxutils-0.1.10/src/oxutils/logger/receivers.py +20 -0
- oxutils-0.1.10/src/oxutils/models/base.py +218 -0
- oxutils-0.1.10/src/oxutils/models/fields.py +79 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/apps.py +6 -1
- oxutils-0.1.10/src/oxutils/oxiliere/authorization.py +45 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/caches.py +8 -7
- oxutils-0.1.10/src/oxutils/oxiliere/checks.py +31 -0
- oxutils-0.1.10/src/oxutils/oxiliere/constants.py +3 -0
- oxutils-0.1.10/src/oxutils/oxiliere/context.py +18 -0
- oxutils-0.1.10/src/oxutils/oxiliere/exceptions.py +16 -0
- oxutils-0.1.10/src/oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/management/commands/init_oxiliere_system.py +20 -8
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/middleware.py +29 -13
- oxutils-0.1.10/src/oxutils/oxiliere/models.py +192 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/permissions.py +6 -5
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/schemas.py +13 -4
- oxutils-0.1.10/src/oxutils/oxiliere/signals.py +5 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/utils.py +18 -0
- oxutils-0.1.10/src/oxutils/pagination/cursor.py +367 -0
- oxutils-0.1.10/src/oxutils/permissions/actions.py +57 -0
- oxutils-0.1.10/src/oxutils/permissions/apps.py +10 -0
- oxutils-0.1.10/src/oxutils/permissions/caches.py +19 -0
- oxutils-0.1.10/src/oxutils/permissions/checks.py +188 -0
- oxutils-0.1.10/src/oxutils/permissions/controllers.py +344 -0
- oxutils-0.1.10/src/oxutils/permissions/exceptions.py +60 -0
- oxutils-0.1.10/src/oxutils/permissions/management/commands/__init__.py +0 -0
- oxutils-0.1.10/src/oxutils/permissions/management/commands/load_permission_preset.py +112 -0
- oxutils-0.1.10/src/oxutils/permissions/migrations/0001_initial.py +112 -0
- oxutils-0.1.10/src/oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
- oxutils-0.1.10/src/oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
- oxutils-0.1.10/src/oxutils/permissions/migrations/__init__.py +0 -0
- oxutils-0.1.10/src/oxutils/permissions/models.py +171 -0
- oxutils-0.1.10/src/oxutils/permissions/perms.py +95 -0
- oxutils-0.1.10/src/oxutils/permissions/queryset.py +92 -0
- oxutils-0.1.10/src/oxutils/permissions/schemas.py +276 -0
- oxutils-0.1.10/src/oxutils/permissions/services.py +663 -0
- oxutils-0.1.10/src/oxutils/permissions/utils.py +628 -0
- oxutils-0.1.10/src/oxutils/py.typed +0 -0
- oxutils-0.1.10/src/oxutils/s3/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/settings.py +1 -0
- oxutils-0.1.10/src/oxutils/users/__init__.py +0 -0
- oxutils-0.1.10/src/oxutils/users/admin.py +3 -0
- oxutils-0.1.10/src/oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
- oxutils-0.1.10/src/oxutils/users/migrations/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/users/models.py +2 -0
- oxutils-0.1.10/src/oxutils/users/tests.py +3 -0
- oxutils-0.1.8/src/oxutils/jwt/auth.py +0 -55
- oxutils-0.1.8/src/oxutils/logger/receivers.py +0 -16
- oxutils-0.1.8/src/oxutils/models/base.py +0 -116
- oxutils-0.1.8/src/oxutils/oxiliere/models.py +0 -81
- {oxutils-0.1.8 → oxutils-0.1.10}/README.md +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/apps.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/apps.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/export.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/masks.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/migrations/0001_initial.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/migrations/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/models.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/settings.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/audit/utils.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/celery/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/celery/base.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/celery/settings.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/conf.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/context/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/context/site_name_processor.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/admin.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/apps.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/controllers.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/enums.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/migrations/0001_initial.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/migrations/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/models.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/schemas.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/tests.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/currency/utils.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/enums/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/enums/audit.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/enums/invoices.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/exceptions.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/functions.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/jwt/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/jwt/tokens.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/jwt/utils.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/locale/fr/LC_MESSAGES/django.po +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/logger/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/logger/settings.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/mixins/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/mixins/base.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/mixins/schemas.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/mixins/services.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/models/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/models/billing.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/models/invoice.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/admin.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/cacheops.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/controllers.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/enums.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/management/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/management/commands/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/migrations/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/settings.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/oxiliere/tests.py +0 -0
- {oxutils-0.1.8/src/oxutils/s3 → oxutils-0.1.10/src/oxutils/pagination}/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/pdf/__init__.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/pdf/printer.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/pdf/utils.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/pdf/views.py +0 -0
- {oxutils-0.1.8/src/oxutils/users → oxutils-0.1.10/src/oxutils/permissions}/__init__.py +0 -0
- {oxutils-0.1.8/src/oxutils/users → oxutils-0.1.10/src/oxutils/permissions}/admin.py +0 -0
- /oxutils-0.1.8/src/oxutils/py.typed → /oxutils-0.1.10/src/oxutils/permissions/constants.py +0 -0
- {oxutils-0.1.8/src/oxutils/users/migrations → oxutils-0.1.10/src/oxutils/permissions/management}/__init__.py +0 -0
- {oxutils-0.1.8/src/oxutils/users → oxutils-0.1.10/src/oxutils/permissions}/tests.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/s3/settings.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/s3/storages.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/types.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/users/apps.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/users/migrations/0001_initial.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/users/utils.py +0 -0
- {oxutils-0.1.8 → oxutils-0.1.10}/src/oxutils/utils.py +0 -0
|
@@ -8,9 +8,10 @@ This package provides:
|
|
|
8
8
|
- Celery integration
|
|
9
9
|
- Django model mixins
|
|
10
10
|
- Custom exceptions
|
|
11
|
+
- Permission management
|
|
11
12
|
"""
|
|
12
13
|
|
|
13
|
-
__version__ = "0.1.
|
|
14
|
+
__version__ = "0.1.10"
|
|
14
15
|
|
|
15
16
|
from oxutils.settings import oxi_settings
|
|
16
17
|
from oxutils.conf import UTILS_APPS, AUDIT_MIDDLEWARE
|
|
@@ -2,3 +2,7 @@ ORGANIZATION_QUERY_KEY = 'organization_id'
|
|
|
2
2
|
ORGANIZATION_HEADER_KEY = 'X-Organization-ID'
|
|
3
3
|
ORGANIZATION_TOKEN_COOKIE_KEY = 'organization_token'
|
|
4
4
|
OXILIERE_SERVICE_TOKEN = 'X-Oxiliere-Token'
|
|
5
|
+
|
|
6
|
+
REFRESH_TOKEN_COOKIE = 'refresh_token'
|
|
7
|
+
ACCESS_TOKEN_COOKIE = 'access_token'
|
|
8
|
+
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, Any, Optional, Type, Tuple
|
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
from django.contrib.auth.models import AbstractUser
|
|
6
|
+
from django.contrib.auth import (
|
|
7
|
+
authenticate as django_authenticate,
|
|
8
|
+
login as django_login,
|
|
9
|
+
get_user_model
|
|
10
|
+
)
|
|
11
|
+
from jwcrypto import jwk
|
|
12
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
13
|
+
|
|
14
|
+
from ninja.security.base import AuthBase
|
|
15
|
+
from ninja_jwt.authentication import (
|
|
16
|
+
JWTBaseAuthentication,
|
|
17
|
+
JWTStatelessUserAuthentication
|
|
18
|
+
)
|
|
19
|
+
from ninja.security import (
|
|
20
|
+
APIKeyCookie,
|
|
21
|
+
HttpBasicAuth,
|
|
22
|
+
)
|
|
23
|
+
from ninja_jwt.exceptions import InvalidToken
|
|
24
|
+
from ninja_jwt.settings import api_settings
|
|
25
|
+
from oxutils.constants import ACCESS_TOKEN_COOKIE
|
|
26
|
+
from oxutils.settings import oxi_settings
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_public_jwk_cache: Optional[jwk.JWK] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_jwks() -> Dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Get JSON Web Key Set (JWKS) for JWT verification.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict containing the public JWK in JWKS format.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ImproperlyConfigured: If jwt_verifying_key is not configured or file doesn't exist.
|
|
44
|
+
"""
|
|
45
|
+
global _public_jwk_cache
|
|
46
|
+
|
|
47
|
+
if oxi_settings.jwt_verifying_key is None:
|
|
48
|
+
raise ImproperlyConfigured(
|
|
49
|
+
"JWT verifying key is not configured. Set OXI_JWT_VERIFYING_KEY environment variable."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
key_path = oxi_settings.jwt_verifying_key
|
|
53
|
+
|
|
54
|
+
if not os.path.exists(key_path):
|
|
55
|
+
raise ImproperlyConfigured(
|
|
56
|
+
f"JWT verifying key file not found at: {key_path}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if _public_jwk_cache is None:
|
|
60
|
+
try:
|
|
61
|
+
with open(key_path, 'r') as f:
|
|
62
|
+
key_data = f.read()
|
|
63
|
+
|
|
64
|
+
_public_jwk_cache = jwk.JWK.from_pem(key_data.encode('utf-8'))
|
|
65
|
+
_public_jwk_cache.update(kid='main')
|
|
66
|
+
except Exception as e:
|
|
67
|
+
raise ImproperlyConfigured(
|
|
68
|
+
f"Failed to load JWT verifying key from {key_path}: {str(e)}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return {"keys": [_public_jwk_cache.export(as_dict=True)]}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear_jwk_cache() -> None:
|
|
75
|
+
"""Clear the cached JWK. Useful for testing or key rotation."""
|
|
76
|
+
global _public_jwk_cache
|
|
77
|
+
_public_jwk_cache = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AuthMixin:
|
|
81
|
+
def jwt_authenticate(self, request: HttpRequest, token: str) -> AbstractUser:
|
|
82
|
+
"""
|
|
83
|
+
Add token_user to the request object, witch will be erased by the jwt_allauth.utils.popolate_user
|
|
84
|
+
function.
|
|
85
|
+
"""
|
|
86
|
+
token_user = super().jwt_authenticate(request, token)
|
|
87
|
+
request.token_user = token_user
|
|
88
|
+
return token_user
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class JWTAuth(AuthMixin, JWTStatelessUserAuthentication):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class JWTCookieAuth(AuthMixin, JWTBaseAuthentication, APIKeyCookie):
|
|
96
|
+
"""
|
|
97
|
+
An authentication plugin that authenticates requests through a JSON web
|
|
98
|
+
token provided in a request header without performing a database lookup to obtain a user instance.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
param_name = ACCESS_TOKEN_COOKIE
|
|
102
|
+
|
|
103
|
+
def authenticate(self, request: HttpRequest, token: str) -> Any:
|
|
104
|
+
return self.jwt_authenticate(request, token)
|
|
105
|
+
|
|
106
|
+
def get_user(self, validated_token: Any) -> Type[AbstractUser]:
|
|
107
|
+
"""
|
|
108
|
+
Returns a stateless user object which is backed by the given validated
|
|
109
|
+
token.
|
|
110
|
+
"""
|
|
111
|
+
if api_settings.USER_ID_CLAIM not in validated_token:
|
|
112
|
+
# The TokenUser class assumes tokens will have a recognizable user
|
|
113
|
+
# identifier claim.
|
|
114
|
+
raise InvalidToken(_("Token contained no recognizable user identification"))
|
|
115
|
+
|
|
116
|
+
return api_settings.TOKEN_USER_CLASS(validated_token)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def authenticate_by_x_session_token(token: str) -> Optional[Tuple]:
|
|
120
|
+
"""
|
|
121
|
+
Copied from allauth.headless.internal.sessionkit, to "select_related"
|
|
122
|
+
"""
|
|
123
|
+
from allauth.headless import app_settings
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
session = app_settings.TOKEN_STRATEGY.lookup_session(token)
|
|
127
|
+
if not session:
|
|
128
|
+
return None
|
|
129
|
+
user_id_str = session.get(SESSION_KEY)
|
|
130
|
+
if user_id_str:
|
|
131
|
+
meta_pk = get_user_model()._meta.pk
|
|
132
|
+
if meta_pk:
|
|
133
|
+
user_id = meta_pk.to_python(user_id_str)
|
|
134
|
+
user = get_user_model().objects.filter(pk=user_id).first()
|
|
135
|
+
if user and user.is_active:
|
|
136
|
+
return (user, session)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class XSessionTokenAuth(AuthBase):
|
|
141
|
+
"""
|
|
142
|
+
This security class uses the X-Session-Token that django-allauth
|
|
143
|
+
is using for authentication purposes.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
openapi_type: str = "apiKey"
|
|
147
|
+
|
|
148
|
+
def __call__(self, request: HttpRequest):
|
|
149
|
+
token = self.get_session_token(request)
|
|
150
|
+
if token:
|
|
151
|
+
user_session = authenticate_by_x_session_token(token)
|
|
152
|
+
if user_session:
|
|
153
|
+
return user_session[0]
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def get_session_token(self, request: HttpRequest) -> Optional[str]:
|
|
157
|
+
"""
|
|
158
|
+
Returns the session token for the given request, by looking up the
|
|
159
|
+
``X-Session-Token`` header. Override this if you want to extract the token
|
|
160
|
+
from e.g. the ``Authorization`` header.
|
|
161
|
+
"""
|
|
162
|
+
if request.session.session_key:
|
|
163
|
+
return request.session.session_key
|
|
164
|
+
|
|
165
|
+
return request.headers.get("X-Session-Token")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class BasicAuth(HttpBasicAuth):
|
|
169
|
+
def authenticate(self, request: HttpRequest, username: str, password: str) -> Optional[Any]:
|
|
170
|
+
user = django_authenticate(email=username, password=password)
|
|
171
|
+
if user and user.is_active:
|
|
172
|
+
django_login(request, user)
|
|
173
|
+
return user
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class BasicNoPasswordAuth(HttpBasicAuth):
|
|
178
|
+
def authenticate(self, request: HttpRequest, username: str, password: str) -> Optional[Any]:
|
|
179
|
+
try:
|
|
180
|
+
user = get_user_model().objects.get(email=username)
|
|
181
|
+
if user and user.is_active:
|
|
182
|
+
django_login(request, user)
|
|
183
|
+
return user
|
|
184
|
+
return None
|
|
185
|
+
except Exception as e:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
x_session_token_auth = XSessionTokenAuth()
|
|
189
|
+
basic_auth = BasicAuth()
|
|
190
|
+
basic_no_password_auth = BasicNoPasswordAuth()
|
|
191
|
+
jwt_auth = JWTAuth()
|
|
192
|
+
jwt_cookie_auth = JWTCookieAuth()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_auth_handlers(auths: List[AuthBase] = []) -> List[AuthBase]:
|
|
198
|
+
"""Auth handler switcher based on settings.DEBUG"""
|
|
199
|
+
from django.conf import settings
|
|
200
|
+
|
|
201
|
+
if settings.DEBUG:
|
|
202
|
+
return auths
|
|
203
|
+
|
|
204
|
+
return [jwt_auth, jwt_cookie_auth]
|
|
@@ -36,6 +36,14 @@ class TokenTenant:
|
|
|
36
36
|
def pk(self):
|
|
37
37
|
return self.id
|
|
38
38
|
|
|
39
|
+
@property
|
|
40
|
+
def is_active(self):
|
|
41
|
+
return self.status == 'active'
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_deleted(self):
|
|
45
|
+
return self.status == 'deleted'
|
|
46
|
+
|
|
39
47
|
@classmethod
|
|
40
48
|
def for_token(cls, token):
|
|
41
49
|
try:
|
|
@@ -59,6 +67,11 @@ class TokenUser(DefaultTonkenUser):
|
|
|
59
67
|
def id(self):
|
|
60
68
|
return UUID(self.token[api_settings.USER_ID_CLAIM])
|
|
61
69
|
|
|
70
|
+
@property
|
|
71
|
+
def oxi_id(self):
|
|
72
|
+
# for compatibility with the User model
|
|
73
|
+
return self.id
|
|
74
|
+
|
|
62
75
|
@cached_property
|
|
63
76
|
def token_created_at(self):
|
|
64
77
|
return self.token.get('cat', None)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from django.contrib.sites.shortcuts import RequestSite
|
|
2
|
+
from django.dispatch import receiver
|
|
3
|
+
import structlog
|
|
4
|
+
from django_structlog import signals
|
|
5
|
+
from oxutils.settings import oxi_settings
|
|
6
|
+
from oxutils.oxiliere.context import get_current_tenant_schema_name
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@receiver(signals.bind_extra_request_metadata)
|
|
10
|
+
def bind_domain(request, logger, **kwargs):
|
|
11
|
+
current_site = RequestSite(request)
|
|
12
|
+
ctx = {
|
|
13
|
+
'domain': current_site.domain,
|
|
14
|
+
'user_id': str(request.user.pk),
|
|
15
|
+
'service': oxi_settings.service_name
|
|
16
|
+
}
|
|
17
|
+
if oxi_settings.multitenancy:
|
|
18
|
+
ctx['tenant'] = get_current_tenant_schema_name()
|
|
19
|
+
|
|
20
|
+
structlog.contextvars.bind_contextvars(**ctx)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import time
|
|
3
|
+
from django.db import models
|
|
4
|
+
from django.db import transaction
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from oxutils.models.fields import MaskedBackupField
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from safedelete.models import SafeDeleteModel
|
|
12
|
+
from safedelete.models import SOFT_DELETE_CASCADE
|
|
13
|
+
from safedelete.signals import post_undelete
|
|
14
|
+
except ImportError:
|
|
15
|
+
from django.dispatch import Signal
|
|
16
|
+
post_undelete = Signal()
|
|
17
|
+
SOFT_DELETE_CASCADE = 2
|
|
18
|
+
|
|
19
|
+
class SafeDeleteModel(models.Model):
|
|
20
|
+
def __new__(cls, *args, **kwargs):
|
|
21
|
+
raise ImportError("django-safedelete is not installed, please install it to use SafeDeleteModel")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UUIDPrimaryKeyMixin(models.Model):
|
|
25
|
+
"""Mixin that provides a UUID primary key field."""
|
|
26
|
+
id = models.UUIDField(
|
|
27
|
+
primary_key=True,
|
|
28
|
+
default=uuid.uuid4,
|
|
29
|
+
editable=False,
|
|
30
|
+
help_text="Unique identifier for this record"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
class Meta:
|
|
34
|
+
abstract = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TimestampMixin(models.Model):
|
|
38
|
+
"""Mixin that provides created_at and updated_at timestamp fields."""
|
|
39
|
+
created_at = models.DateTimeField(
|
|
40
|
+
auto_now_add=True,
|
|
41
|
+
help_text="Date and time when this record was created"
|
|
42
|
+
)
|
|
43
|
+
updated_at = models.DateTimeField(
|
|
44
|
+
auto_now=True,
|
|
45
|
+
help_text="Date and time when this record was last updated"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
class Meta:
|
|
49
|
+
abstract = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UserTrackingMixin(models.Model):
|
|
53
|
+
"""Mixin that tracks which user created and last modified a record."""
|
|
54
|
+
created_by = models.ForeignKey(
|
|
55
|
+
settings.AUTH_USER_MODEL,
|
|
56
|
+
on_delete=models.SET_NULL,
|
|
57
|
+
null=True,
|
|
58
|
+
blank=True,
|
|
59
|
+
related_name="%(app_label)s_%(class)s_created",
|
|
60
|
+
help_text="User who created this record"
|
|
61
|
+
)
|
|
62
|
+
updated_by = models.ForeignKey(
|
|
63
|
+
settings.AUTH_USER_MODEL,
|
|
64
|
+
on_delete=models.SET_NULL,
|
|
65
|
+
null=True,
|
|
66
|
+
blank=True,
|
|
67
|
+
related_name="%(app_label)s_%(class)s_updated",
|
|
68
|
+
help_text="User who last updated this record"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
class Meta:
|
|
72
|
+
abstract = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SlugMixin(models.Model):
|
|
76
|
+
"""Mixin that provides a slug field."""
|
|
77
|
+
slug = models.SlugField(
|
|
78
|
+
max_length=255,
|
|
79
|
+
unique=True,
|
|
80
|
+
help_text="URL-friendly version of the name"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
class Meta:
|
|
84
|
+
abstract = True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NameMixin(models.Model):
|
|
88
|
+
"""Mixin that provides name and description fields."""
|
|
89
|
+
name = models.CharField(
|
|
90
|
+
max_length=255,
|
|
91
|
+
help_text="Name of this record"
|
|
92
|
+
)
|
|
93
|
+
description = models.TextField(
|
|
94
|
+
blank=True,
|
|
95
|
+
help_text="Optional description"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
class Meta:
|
|
99
|
+
abstract = True
|
|
100
|
+
|
|
101
|
+
def __str__(self):
|
|
102
|
+
return self.name
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ActiveMixin(models.Model):
|
|
106
|
+
"""Mixin that provides an active/inactive status field."""
|
|
107
|
+
is_active = models.BooleanField(
|
|
108
|
+
default=True,
|
|
109
|
+
help_text="Whether this record is active"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
class Meta:
|
|
113
|
+
abstract = True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class OrderingMixin(models.Model):
|
|
117
|
+
"""Mixin that provides an ordering field."""
|
|
118
|
+
order = models.PositiveIntegerField(
|
|
119
|
+
default=0,
|
|
120
|
+
help_text="Order for sorting records"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
class Meta:
|
|
124
|
+
abstract = True
|
|
125
|
+
ordering = ['order']
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class BaseModelMixin(UUIDPrimaryKeyMixin, TimestampMixin, ActiveMixin):
|
|
129
|
+
"""
|
|
130
|
+
Base mixin that combines the most commonly used mixins.
|
|
131
|
+
Provides UUID primary key, timestamps, and active status.
|
|
132
|
+
"""
|
|
133
|
+
class Meta:
|
|
134
|
+
abstract = True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SafeDeleteModelMixin(SafeDeleteModel):
|
|
138
|
+
_safedelete_policy = SOFT_DELETE_CASCADE
|
|
139
|
+
mask_fields = []
|
|
140
|
+
|
|
141
|
+
_masked_backup = MaskedBackupField(default=dict, editable=False)
|
|
142
|
+
|
|
143
|
+
class Meta:
|
|
144
|
+
abstract = True
|
|
145
|
+
|
|
146
|
+
@transaction.atomic
|
|
147
|
+
def delete(self, *args, **kwargs):
|
|
148
|
+
backup = {}
|
|
149
|
+
|
|
150
|
+
for field_name in self.mask_fields:
|
|
151
|
+
field = self._meta.get_field(field_name)
|
|
152
|
+
old_value = getattr(self, field_name)
|
|
153
|
+
|
|
154
|
+
if old_value is None:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
backup[field_name] = old_value
|
|
158
|
+
masked = self._mask_value(field, old_value)
|
|
159
|
+
setattr(self, field_name, masked)
|
|
160
|
+
|
|
161
|
+
if backup:
|
|
162
|
+
self._masked_backup = backup
|
|
163
|
+
self.save(update_fields=[*backup.keys(), "_masked_backup"])
|
|
164
|
+
|
|
165
|
+
return super().delete(*args, **kwargs)
|
|
166
|
+
|
|
167
|
+
def _mask_value(self, field: models.Field, old_value):
|
|
168
|
+
uid = uuid.uuid4().hex
|
|
169
|
+
ts = int(time.time())
|
|
170
|
+
|
|
171
|
+
if isinstance(field, models.EmailField):
|
|
172
|
+
return f"{ts}.{uid}.deleted@invalid.local"
|
|
173
|
+
|
|
174
|
+
if isinstance(field, models.URLField):
|
|
175
|
+
return f"https://deleted.invalid/{ts}/{uid}"
|
|
176
|
+
|
|
177
|
+
if isinstance(field, models.SlugField):
|
|
178
|
+
return f"deleted-{ts}-{uid}"
|
|
179
|
+
|
|
180
|
+
if isinstance(field, models.CharField):
|
|
181
|
+
return f"__deleted__{ts}__{uid}"
|
|
182
|
+
|
|
183
|
+
if isinstance(field, models.IntegerField):
|
|
184
|
+
return None # souvent OK, sinon adapte
|
|
185
|
+
|
|
186
|
+
# fallback générique
|
|
187
|
+
return f"deleted-{ts}-{uid}"
|
|
188
|
+
|
|
189
|
+
@transaction.atomic
|
|
190
|
+
def restore_masked_fields(self):
|
|
191
|
+
if not self._masked_backup:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
for field_name, old_value in self._masked_backup.items():
|
|
195
|
+
field = self._meta.get_field(field_name)
|
|
196
|
+
|
|
197
|
+
# vérification collision
|
|
198
|
+
qs = self.__class__._default_manager.filter(
|
|
199
|
+
**{field_name: old_value}
|
|
200
|
+
).exclude(pk=self.pk)
|
|
201
|
+
|
|
202
|
+
if qs.exists():
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Collision détectée lors de la restauration du champ '{field_name}'"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
setattr(self, field_name, old_value)
|
|
208
|
+
|
|
209
|
+
self._masked_backup = {}
|
|
210
|
+
self.save()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _restore_masked_fields(sender, instance, **kwargs):
|
|
214
|
+
if isinstance(instance, SafeDeleteModelMixin):
|
|
215
|
+
instance.restore_masked_fields()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
post_undelete.connect(_restore_masked_fields)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""# settings.py
|
|
2
|
+
|
|
3
|
+
FIELD_MASKING_CRYPTO_ENABLED = True # switch global
|
|
4
|
+
|
|
5
|
+
# optionnel (recommandé)
|
|
6
|
+
FIELD_MASKING_KEY = env("FIELD_MASKING_KEY", default=None)
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
from django.utils.functional import cached_property
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.db import models
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_field_masking_fernet():
|
|
22
|
+
from cryptography.fernet import Fernet
|
|
23
|
+
|
|
24
|
+
if not hasattr(settings, "FIELD_MASKING_CRYPTO_ENABLED") or not settings.FIELD_MASKING_CRYPTO_ENABLED:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
if not hasattr(settings, "FIELD_MASKING_KEY") or settings.FIELD_MASKING_KEY:
|
|
28
|
+
return Fernet(settings.FIELD_MASKING_KEY)
|
|
29
|
+
|
|
30
|
+
# fallback contrôlé
|
|
31
|
+
digest = hashlib.sha256(
|
|
32
|
+
(settings.SECRET_KEY + ":field-masking:v1").encode()
|
|
33
|
+
).digest()
|
|
34
|
+
|
|
35
|
+
key = base64.urlsafe_b64encode(digest)
|
|
36
|
+
return Fernet(key)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MaskedBackupField(models.TextField):
|
|
41
|
+
"""
|
|
42
|
+
JSONField avec chiffrement optionnel
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@cached_property
|
|
46
|
+
def fernet(self):
|
|
47
|
+
return get_field_masking_fernet()
|
|
48
|
+
|
|
49
|
+
def get_prep_value(self, value):
|
|
50
|
+
if value in (None, ""):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
raw = json.dumps(value).encode()
|
|
54
|
+
|
|
55
|
+
if not self.fernet:
|
|
56
|
+
return raw.decode()
|
|
57
|
+
|
|
58
|
+
return self.fernet.encrypt(raw).decode()
|
|
59
|
+
|
|
60
|
+
def from_db_value(self, value, expression, connection):
|
|
61
|
+
return self.to_python(value)
|
|
62
|
+
|
|
63
|
+
def to_python(self, value):
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
if value in (None, ""):
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if self.fernet:
|
|
72
|
+
decrypted = self.fernet.decrypt(value.encode())
|
|
73
|
+
return json.loads(decrypted.decode())
|
|
74
|
+
|
|
75
|
+
return json.loads(value)
|
|
76
|
+
|
|
77
|
+
except Exception:
|
|
78
|
+
# sécurité : ne jamais casser un fetch DB
|
|
79
|
+
return {}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.db import transaction
|
|
3
|
+
from oxutils.permissions.actions import ACTIONS
|
|
4
|
+
from oxutils.permissions.models import Grant, Group, UserGroup
|
|
5
|
+
from oxutils.oxiliere.utils import get_tenant_user_model
|
|
6
|
+
from oxutils.oxiliere.models import BaseTenant
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@transaction.atomic
|
|
10
|
+
def grant_manager_access_to_owners(tenant: BaseTenant):
|
|
11
|
+
tenant_user_model = get_tenant_user_model()
|
|
12
|
+
tenant_users = tenant_user_model.objects.select_related("user").filter(tenant=tenant, is_owner=True)
|
|
13
|
+
|
|
14
|
+
access_scope = getattr(settings, 'ACCESS_MANAGER_SCOPE')
|
|
15
|
+
access_group = getattr(settings, 'ACCESS_MANAGER_GROUP')
|
|
16
|
+
|
|
17
|
+
if access_group:
|
|
18
|
+
try:
|
|
19
|
+
group = Group.objects.get(slug=access_group)
|
|
20
|
+
except Group.DoesNotExist:
|
|
21
|
+
group = None
|
|
22
|
+
|
|
23
|
+
bulk_grant = []
|
|
24
|
+
for tenant_user in tenant_users:
|
|
25
|
+
if group:
|
|
26
|
+
user_group, _ = UserGroup.objects.get_or_create(
|
|
27
|
+
user=tenant_user.user,
|
|
28
|
+
group=group,
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
user_group = None
|
|
32
|
+
|
|
33
|
+
bulk_grant.append(
|
|
34
|
+
Grant(
|
|
35
|
+
user=tenant_user.user,
|
|
36
|
+
scope=access_scope,
|
|
37
|
+
role=None,
|
|
38
|
+
actions=ACTIONS,
|
|
39
|
+
context={},
|
|
40
|
+
user_group=user_group,
|
|
41
|
+
created_by=None,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
Grant.objects.bulk_create(bulk_grant)
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
from django.conf import settings
|
|
2
2
|
from cacheops import cached_as, cached
|
|
3
|
-
from oxutils.oxiliere.utils import
|
|
3
|
+
from oxutils.oxiliere.utils import (
|
|
4
|
+
get_tenant_model,
|
|
5
|
+
get_tenant_user_model,
|
|
6
|
+
get_system_tenant_schema_name
|
|
7
|
+
)
|
|
8
|
+
|
|
4
9
|
|
|
5
10
|
|
|
6
11
|
|
|
@@ -28,9 +33,5 @@ def get_tenant_user(oxi_org_id: str, oxi_user_id: str):
|
|
|
28
33
|
|
|
29
34
|
@cached(timeout=60*15)
|
|
30
35
|
def get_system_tenant():
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
system_schema_name = oxid_to_schema_name(
|
|
34
|
-
getattr(settings, 'OXI_SYSTEM_TENANT', 'tenant_oxisystem')
|
|
35
|
-
)
|
|
36
|
-
return get_tenant_model().objects.get(schema_name=system_schema_name)
|
|
36
|
+
schema_name = get_system_tenant_schema_name()
|
|
37
|
+
return get_tenant_model().objects.get(schema_name=schema_name)
|