oxutils 0.1.6__py3-none-any.whl → 0.1.12__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 -2
- oxutils/audit/migrations/0001_initial.py +2 -2
- oxutils/audit/models.py +2 -2
- oxutils/constants.py +6 -0
- oxutils/jwt/auth.py +150 -1
- oxutils/jwt/models.py +81 -0
- oxutils/jwt/tokens.py +69 -0
- oxutils/jwt/utils.py +45 -0
- oxutils/logger/__init__.py +10 -0
- oxutils/logger/receivers.py +10 -6
- oxutils/logger/settings.py +2 -2
- oxutils/models/base.py +102 -0
- oxutils/models/fields.py +79 -0
- oxutils/oxiliere/apps.py +9 -1
- oxutils/oxiliere/authorization.py +45 -0
- oxutils/oxiliere/caches.py +13 -11
- oxutils/oxiliere/checks.py +31 -0
- oxutils/oxiliere/constants.py +3 -0
- oxutils/oxiliere/context.py +16 -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 +30 -11
- oxutils/oxiliere/middleware.py +65 -11
- oxutils/oxiliere/models.py +146 -9
- oxutils/oxiliere/permissions.py +28 -35
- oxutils/oxiliere/schemas.py +16 -6
- oxutils/oxiliere/signals.py +5 -0
- oxutils/oxiliere/utils.py +36 -1
- 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 +14 -194
- oxutils/users/apps.py +1 -1
- oxutils/users/migrations/0001_initial.py +47 -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.6.dist-info → oxutils-0.1.12.dist-info}/METADATA +14 -11
- oxutils-0.1.12.dist-info/RECORD +122 -0
- oxutils/jwt/client.py +0 -123
- oxutils/jwt/constants.py +0 -1
- oxutils/s3/settings.py +0 -34
- oxutils/s3/storages.py +0 -130
- oxutils-0.1.6.dist-info/RECORD +0 -88
- /oxutils/{s3 → pagination}/__init__.py +0 -0
- {oxutils-0.1.6.dist-info → oxutils-0.1.12.dist-info}/WHEEL +0 -0
oxutils/__init__.py
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
This package provides:
|
|
4
4
|
- JWT authentication with JWKS support
|
|
5
|
-
- S3 storage backends (static, media, private, logs)
|
|
6
5
|
- Structured logging with correlation IDs
|
|
7
6
|
- Audit system with S3 export
|
|
8
7
|
- Celery integration
|
|
9
8
|
- Django model mixins
|
|
10
9
|
- Custom exceptions
|
|
10
|
+
- Permission management
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
__version__ = "0.1.
|
|
13
|
+
__version__ = "0.1.12"
|
|
14
14
|
|
|
15
15
|
from oxutils.settings import oxi_settings
|
|
16
16
|
from oxutils.conf import UTILS_APPS, AUDIT_MIDDLEWARE
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import django.db.models.deletion
|
|
4
4
|
import oxutils.enums.audit
|
|
5
|
-
|
|
5
|
+
from oxutils.logger import get_log_storage
|
|
6
6
|
from django.db import migrations, models
|
|
7
7
|
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
|
|
|
22
22
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Date and time when this record was last updated')),
|
|
23
23
|
('last_export_date', models.DateTimeField(null=True)),
|
|
24
24
|
('status', models.CharField(choices=[(oxutils.enums.audit.ExportStatus['FAILED'], 'Failed'), (oxutils.enums.audit.ExportStatus['PENDING'], 'Pending'), (oxutils.enums.audit.ExportStatus['SUCCESS'], 'Success')], default=oxutils.enums.audit.ExportStatus['PENDING'])),
|
|
25
|
-
('data', models.FileField(storage=
|
|
25
|
+
('data', models.FileField(storage=get_log_storage, upload_to='')),
|
|
26
26
|
('size', models.BigIntegerField()),
|
|
27
27
|
],
|
|
28
28
|
options={
|
oxutils/audit/models.py
CHANGED
|
@@ -3,7 +3,7 @@ from django.utils import timezone
|
|
|
3
3
|
from django.db import models, transaction
|
|
4
4
|
from oxutils.enums.audit import ExportStatus
|
|
5
5
|
from oxutils.models.base import TimestampMixin
|
|
6
|
-
from oxutils.
|
|
6
|
+
from oxutils.logger import get_log_storage
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
|
|
@@ -38,7 +38,7 @@ class LogExportState(TimestampMixin):
|
|
|
38
38
|
(ExportStatus.SUCCESS, _('Success'))
|
|
39
39
|
)
|
|
40
40
|
)
|
|
41
|
-
data = models.FileField(storage=
|
|
41
|
+
data = models.FileField(storage=get_log_storage)
|
|
42
42
|
size = models.BigIntegerField()
|
|
43
43
|
|
|
44
44
|
@classmethod
|
oxutils/constants.py
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
ORGANIZATION_QUERY_KEY = 'organization_id'
|
|
2
2
|
ORGANIZATION_HEADER_KEY = 'X-Organization-ID'
|
|
3
|
+
ORGANIZATION_TOKEN_COOKIE_KEY = 'organization_token'
|
|
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, List
|
|
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
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from .tokens import OrganizationAccessToken
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TokenTenant:
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
schema_name: str,
|
|
19
|
+
tenant_id: int,
|
|
20
|
+
oxi_id: str,
|
|
21
|
+
subscription_plan: str,
|
|
22
|
+
subscription_status: str,
|
|
23
|
+
status: str,
|
|
24
|
+
):
|
|
25
|
+
self.schema_name = schema_name
|
|
26
|
+
self.id = tenant_id
|
|
27
|
+
self.oxi_id = oxi_id
|
|
28
|
+
self.subscription_plan = subscription_plan
|
|
29
|
+
self.subscription_status = subscription_status
|
|
30
|
+
self.status = status
|
|
31
|
+
|
|
32
|
+
def __str__(self):
|
|
33
|
+
return f"{self.schema_name} - {self.oxi_id}"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def pk(self):
|
|
37
|
+
return self.id
|
|
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
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def for_token(cls, token):
|
|
49
|
+
try:
|
|
50
|
+
token_obj = OrganizationAccessToken(token=token)
|
|
51
|
+
tenant = cls(
|
|
52
|
+
schema_name=token_obj['schema_name'],
|
|
53
|
+
tenant_id=token_obj['tenant_id'],
|
|
54
|
+
oxi_id=token_obj['oxi_id'],
|
|
55
|
+
subscription_plan=token_obj['subscription_plan'],
|
|
56
|
+
subscription_status=token_obj['subscription_status'],
|
|
57
|
+
status=token_obj['status'],
|
|
58
|
+
)
|
|
59
|
+
return tenant
|
|
60
|
+
except Exception:
|
|
61
|
+
logger.exception('Failed to create TokenTenant from token', token=token)
|
|
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/tokens.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from ninja_jwt.tokens import Token, AccessToken
|
|
3
|
+
from ninja_jwt.backends import TokenBackend
|
|
4
|
+
from ninja_jwt.settings import api_settings
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from oxutils.settings import oxi_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
'AccessToken',
|
|
13
|
+
'OrganizationAccessToken',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
token_backend = TokenBackend(
|
|
19
|
+
algorithm='HS256',
|
|
20
|
+
signing_key=settings.SECRET_KEY,
|
|
21
|
+
verifying_key="",
|
|
22
|
+
audience=None,
|
|
23
|
+
issuer=None,
|
|
24
|
+
jwk_url=None,
|
|
25
|
+
leeway=api_settings.LEEWAY,
|
|
26
|
+
json_encoder=api_settings.JSON_ENCODER,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OxilierServiceToken(Token):
|
|
32
|
+
token_type = oxi_settings.jwt_service_token_key
|
|
33
|
+
lifetime = timedelta(minutes=oxi_settings.jwt_service_token_lifetime)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def for_service(cls, payload: dict = {}) -> Token:
|
|
38
|
+
token = cls()
|
|
39
|
+
|
|
40
|
+
for key, value in payload.items():
|
|
41
|
+
token[key] = value
|
|
42
|
+
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OrganizationAccessToken(Token):
|
|
47
|
+
token_type = oxi_settings.jwt_org_access_token_key
|
|
48
|
+
lifetime = timedelta(minutes=oxi_settings.jwt_org_access_token_lifetime)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def for_tenant(cls, tenant) -> Token:
|
|
52
|
+
token = cls()
|
|
53
|
+
token.payload['tenant_id'] = str(tenant.id)
|
|
54
|
+
token.payload['oxi_id'] = str(tenant.oxi_id)
|
|
55
|
+
token.payload['schema_name'] = str(tenant.schema_name)
|
|
56
|
+
token.payload['subscription_plan'] = str(tenant.subscription_plan)
|
|
57
|
+
token.payload['subscription_status'] = str(tenant.subscription_status)
|
|
58
|
+
token.payload['subscription_end_date'] = str(tenant.subscription_end_date)
|
|
59
|
+
token.payload['status'] = str(tenant.status)
|
|
60
|
+
|
|
61
|
+
return token
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def token_backend(self):
|
|
65
|
+
return token_backend
|
|
66
|
+
|
|
67
|
+
def get_token_backend(self):
|
|
68
|
+
# Backward compatibility.
|
|
69
|
+
return self.token_backend
|
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/__init__.py
CHANGED
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/logger/settings.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import structlog
|
|
2
|
-
|
|
2
|
+
from oxutils.settings import oxi_settings
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
|
|
@@ -29,7 +29,7 @@ LOGGING = {
|
|
|
29
29
|
},
|
|
30
30
|
"json_file": {
|
|
31
31
|
"class": "logging.handlers.WatchedFileHandler",
|
|
32
|
-
"filename":
|
|
32
|
+
"filename": oxi_settings.log_file_path,
|
|
33
33
|
"formatter": "json_formatter",
|
|
34
34
|
},
|
|
35
35
|
},
|
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
|
@@ -3,4 +3,12 @@ from django.apps import AppConfig
|
|
|
3
3
|
|
|
4
4
|
class OxiliereConfig(AppConfig):
|
|
5
5
|
default_auto_field = 'django.db.models.BigAutoField'
|
|
6
|
-
name = 'oxiliere'
|
|
6
|
+
name = 'oxutils.oxiliere'
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
import oxutils.oxiliere.checks
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import oxutils.oxiliere.caches
|
|
13
|
+
except LookupError:
|
|
14
|
+
pass
|