oxutils 0.1.7__py3-none-any.whl → 0.1.10__py3-none-any.whl
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/__init__.py +2 -1
- oxutils/constants.py +4 -0
- oxutils/jwt/auth.py +150 -1
- oxutils/jwt/models.py +32 -1
- oxutils/jwt/utils.py +45 -0
- oxutils/logger/receivers.py +10 -6
- oxutils/models/base.py +102 -0
- oxutils/models/fields.py +79 -0
- oxutils/oxiliere/apps.py +6 -1
- oxutils/oxiliere/authorization.py +45 -0
- oxutils/oxiliere/caches.py +8 -7
- oxutils/oxiliere/checks.py +31 -0
- oxutils/oxiliere/constants.py +3 -0
- oxutils/oxiliere/context.py +18 -0
- oxutils/oxiliere/exceptions.py +16 -0
- oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
- oxutils/oxiliere/management/commands/init_oxiliere_system.py +20 -8
- oxutils/oxiliere/middleware.py +29 -13
- oxutils/oxiliere/models.py +130 -19
- oxutils/oxiliere/permissions.py +6 -5
- oxutils/oxiliere/schemas.py +13 -4
- oxutils/oxiliere/signals.py +5 -0
- oxutils/oxiliere/utils.py +18 -0
- oxutils/pagination/__init__.py +0 -0
- oxutils/pagination/cursor.py +367 -0
- oxutils/permissions/__init__.py +0 -0
- oxutils/permissions/actions.py +57 -0
- oxutils/permissions/admin.py +3 -0
- oxutils/permissions/apps.py +10 -0
- oxutils/permissions/caches.py +19 -0
- oxutils/permissions/checks.py +188 -0
- oxutils/permissions/constants.py +0 -0
- oxutils/permissions/controllers.py +344 -0
- oxutils/permissions/exceptions.py +60 -0
- oxutils/permissions/management/__init__.py +0 -0
- oxutils/permissions/management/commands/__init__.py +0 -0
- oxutils/permissions/management/commands/load_permission_preset.py +112 -0
- oxutils/permissions/migrations/0001_initial.py +112 -0
- oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
- oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
- oxutils/permissions/migrations/__init__.py +0 -0
- oxutils/permissions/models.py +171 -0
- oxutils/permissions/perms.py +95 -0
- oxutils/permissions/queryset.py +92 -0
- oxutils/permissions/schemas.py +276 -0
- oxutils/permissions/services.py +663 -0
- oxutils/permissions/tests.py +3 -0
- oxutils/permissions/utils.py +628 -0
- oxutils/settings.py +1 -0
- oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
- oxutils/users/models.py +2 -0
- oxutils/utils.py +25 -0
- {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/METADATA +1 -1
- {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/RECORD +55 -19
- {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/WHEEL +0 -0
oxutils/__init__.py
CHANGED
|
@@ -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
|
oxutils/constants.py
CHANGED
|
@@ -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
|
+
|
oxutils/jwt/auth.py
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import os
|
|
2
|
-
from typing import Dict, Any, Optional
|
|
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
|
+
)
|
|
3
11
|
from jwcrypto import jwk
|
|
4
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
|
|
5
26
|
from oxutils.settings import oxi_settings
|
|
6
27
|
|
|
7
28
|
|
|
8
29
|
|
|
30
|
+
|
|
9
31
|
_public_jwk_cache: Optional[jwk.JWK] = None
|
|
10
32
|
|
|
11
33
|
|
|
@@ -53,3 +75,130 @@ def clear_jwk_cache() -> None:
|
|
|
53
75
|
"""Clear the cached JWK. Useful for testing or key rotation."""
|
|
54
76
|
global _public_jwk_cache
|
|
55
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]
|
oxutils/jwt/models.py
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
from django.utils.functional import cached_property
|
|
3
|
+
from ninja_jwt.models import TokenUser as DefaultTonkenUser
|
|
4
|
+
from ninja_jwt.settings import api_settings
|
|
5
|
+
|
|
2
6
|
import structlog
|
|
3
7
|
from .tokens import OrganizationAccessToken
|
|
4
8
|
|
|
@@ -32,6 +36,14 @@ class TokenTenant:
|
|
|
32
36
|
def pk(self):
|
|
33
37
|
return self.id
|
|
34
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
|
+
|
|
35
47
|
@classmethod
|
|
36
48
|
def for_token(cls, token):
|
|
37
49
|
try:
|
|
@@ -48,3 +60,22 @@ class TokenTenant:
|
|
|
48
60
|
except Exception:
|
|
49
61
|
logger.exception('Failed to create TokenTenant from token', token=token)
|
|
50
62
|
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TokenUser(DefaultTonkenUser):
|
|
66
|
+
@cached_property
|
|
67
|
+
def id(self):
|
|
68
|
+
return UUID(self.token[api_settings.USER_ID_CLAIM])
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def oxi_id(self):
|
|
72
|
+
# for compatibility with the User model
|
|
73
|
+
return self.id
|
|
74
|
+
|
|
75
|
+
@cached_property
|
|
76
|
+
def token_created_at(self):
|
|
77
|
+
return self.token.get('cat', None)
|
|
78
|
+
|
|
79
|
+
@cached_property
|
|
80
|
+
def token_session(self):
|
|
81
|
+
return self.token.get('session', None)
|
oxutils/jwt/utils.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
import structlog
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
|
|
6
|
+
from ninja_jwt.exceptions import InvalidToken
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = structlog.getLogger("django")
|
|
11
|
+
User = get_user_model()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_user(f):
|
|
15
|
+
"""
|
|
16
|
+
Decorator that loads the complete user object from the database for stateless JWT authentication.
|
|
17
|
+
This is necessary because JWT tokens only contain the user ID, and the full user object
|
|
18
|
+
might be needed in the view methods.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
|
|
22
|
+
.. code-block:: python
|
|
23
|
+
|
|
24
|
+
@load_user
|
|
25
|
+
def my_view_method(self, *args, **kwargs):
|
|
26
|
+
# self.request.user will be the complete user object
|
|
27
|
+
pass
|
|
28
|
+
"""
|
|
29
|
+
@wraps(f)
|
|
30
|
+
def wrapper(self, *args, **kwargs):
|
|
31
|
+
populate_user(self.context.request)
|
|
32
|
+
res = f(self, *args, **kwargs)
|
|
33
|
+
return res
|
|
34
|
+
return wrapper
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def populate_user(request: HttpRequest):
|
|
38
|
+
if isinstance(request.user, User):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
request.user = User.objects.get(oxi_id=request.user.id)
|
|
43
|
+
except User.DoesNotExist as exc:
|
|
44
|
+
logger.exception('user_not_found', oxi_id=request.user.id, message=str(exc))
|
|
45
|
+
raise InvalidToken()
|
oxutils/logger/receivers.py
CHANGED
|
@@ -3,14 +3,18 @@ from django.dispatch import receiver
|
|
|
3
3
|
import structlog
|
|
4
4
|
from django_structlog import signals
|
|
5
5
|
from oxutils.settings import oxi_settings
|
|
6
|
-
|
|
6
|
+
from oxutils.oxiliere.context import get_current_tenant_schema_name
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
@receiver(signals.bind_extra_request_metadata)
|
|
10
10
|
def bind_domain(request, logger, **kwargs):
|
|
11
11
|
current_site = RequestSite(request)
|
|
12
|
-
|
|
13
|
-
domain
|
|
14
|
-
user_id
|
|
15
|
-
service
|
|
16
|
-
|
|
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)
|
oxutils/models/base.py
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import uuid
|
|
2
|
+
import time
|
|
2
3
|
from django.db import models
|
|
4
|
+
from django.db import transaction
|
|
3
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")
|
|
4
22
|
|
|
5
23
|
|
|
6
24
|
class UUIDPrimaryKeyMixin(models.Model):
|
|
@@ -114,3 +132,87 @@ class BaseModelMixin(UUIDPrimaryKeyMixin, TimestampMixin, ActiveMixin):
|
|
|
114
132
|
"""
|
|
115
133
|
class Meta:
|
|
116
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)
|
oxutils/models/fields.py
ADDED
|
@@ -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 {}
|
oxutils/oxiliere/apps.py
CHANGED
|
@@ -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)
|
oxutils/oxiliere/caches.py
CHANGED
|
@@ -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)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Check TENANT_MODEL & TENANT_USER_MODEL
|
|
3
|
+
"""
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.core.checks import Error, register, Tags
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register(Tags.models)
|
|
9
|
+
def check_tenant_settings(app_configs, **kwargs):
|
|
10
|
+
"""Check that TENANT_MODEL and TENANT_USER_MODEL are defined in settings."""
|
|
11
|
+
errors = []
|
|
12
|
+
|
|
13
|
+
if not hasattr(settings, 'TENANT_MODEL'):
|
|
14
|
+
errors.append(
|
|
15
|
+
Error(
|
|
16
|
+
'TENANT_MODEL is not defined in settings',
|
|
17
|
+
hint='Add TENANT_MODEL = "app_label.ModelName" to your settings',
|
|
18
|
+
id='oxiliere.E001',
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if not hasattr(settings, 'TENANT_USER_MODEL'):
|
|
23
|
+
errors.append(
|
|
24
|
+
Error(
|
|
25
|
+
'TENANT_USER_MODEL is not defined in settings',
|
|
26
|
+
hint='Add TENANT_USER_MODEL = "app_label.ModelName" to your settings',
|
|
27
|
+
id='oxiliere.E002',
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return errors
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
from oxutils.oxiliere.utils import get_system_tenant_schema_name
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
current_tenant_schema_name: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
6
|
+
"current_tenant_schema_name",
|
|
7
|
+
default=get_system_tenant_schema_name()
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_current_tenant_schema_name() -> str:
|
|
12
|
+
return current_tenant_schema_name.get()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_current_tenant_schema_name(schema_name: str):
|
|
16
|
+
current_tenant_schema_name.set(schema_name)
|
|
17
|
+
|
|
18
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
from django.db import connection
|
|
3
|
+
from django_tenants.management.commands import InteractiveTenantOption
|
|
4
|
+
from oxutils.oxiliere.authorization import grant_manager_access_to_owners
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Command(InteractiveTenantOption, BaseCommand):
|
|
8
|
+
help = "Wrapper around django commands for use with an individual tenant"
|
|
9
|
+
|
|
10
|
+
def add_arguments(self, parser):
|
|
11
|
+
super().add_arguments(parser)
|
|
12
|
+
|
|
13
|
+
def handle(self, *args, **options):
|
|
14
|
+
tenant = self.get_tenant_from_options_or_interactive(**options)
|
|
15
|
+
connection.set_tenant(tenant)
|
|
16
|
+
options.pop('schema_name', None)
|
|
17
|
+
|
|
18
|
+
grant_manager_access_to_owners(tenant)
|
|
19
|
+
self.stdout.write(self.style.SUCCESS('Successfully granted manager access to owners'))
|