oxutils 0.1.14__py3-none-any.whl → 0.1.15__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 CHANGED
@@ -10,7 +10,7 @@ This package provides:
10
10
  - Permission management
11
11
  """
12
12
 
13
- __version__ = "0.1.14"
13
+ __version__ = "0.1.15"
14
14
 
15
15
  from oxutils.settings import oxi_settings
16
16
  from oxutils.conf import UTILS_APPS, AUDIT_MIDDLEWARE
oxutils/jwt/auth.py CHANGED
@@ -17,6 +17,7 @@ from ninja_jwt.authentication import (
17
17
  JWTStatelessUserAuthentication
18
18
  )
19
19
  from ninja.security import (
20
+ HttpBearer,
20
21
  APIKeyCookie,
21
22
  HttpBasicAuth,
22
23
  )
@@ -176,6 +177,11 @@ class BasicAuth(HttpBasicAuth):
176
177
 
177
178
  class BasicNoPasswordAuth(HttpBasicAuth):
178
179
  def authenticate(self, request: HttpRequest, username: str, password: str) -> Optional[Any]:
180
+
181
+ # check if the middleware have already authenticated the user
182
+ if request.user.is_authenticated:
183
+ return request.user
184
+
179
185
  try:
180
186
  user = get_user_model().objects.get(email=username)
181
187
  if user and user.is_active:
@@ -185,12 +191,34 @@ class BasicNoPasswordAuth(HttpBasicAuth):
185
191
  except Exception as e:
186
192
  return None
187
193
 
188
- x_session_token_auth = XSessionTokenAuth()
194
+
195
+ class JWTPassiveAuth(HttpBearer):
196
+ def authenticate(self, request: HttpRequest, token: str) -> Optional[Any]:
197
+ if request.user.is_authenticated:
198
+ return request.user
199
+ return None
200
+
201
+
202
+ class JWTCookiePassiveAuth(APIKeyCookie):
203
+ def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[Any]:
204
+ if request.user.is_authenticated:
205
+ return request.user
206
+ return None
207
+
208
+
209
+ # for development Purpose only
189
210
  basic_auth = BasicAuth()
190
211
  basic_no_password_auth = BasicNoPasswordAuth()
212
+
213
+ # used on oxiliere authentication service
214
+ x_session_token_auth = XSessionTokenAuth()
191
215
  jwt_auth = JWTAuth()
192
216
  jwt_cookie_auth = JWTCookieAuth()
193
217
 
218
+ # Production in all oxiliere services
219
+ jwt_passive_auth = JWTPassiveAuth()
220
+ jwt_cookie_passive_auth = JWTCookiePassiveAuth()
221
+
194
222
 
195
223
 
196
224
 
@@ -202,3 +230,13 @@ def get_auth_handlers(auths: List[AuthBase] = []) -> List[AuthBase]:
202
230
  return auths
203
231
 
204
232
  return [jwt_auth, jwt_cookie_auth]
233
+
234
+
235
+ def get_passive_auth_handlers(auths: List[AuthBase] = []) -> List[AuthBase]:
236
+ """Passive auth handler switcher based on settings.DEBUG"""
237
+ from django.conf import settings
238
+
239
+ if settings.DEBUG:
240
+ return auths
241
+
242
+ return [jwt_passive_auth, jwt_cookie_passive_auth]
@@ -0,0 +1,404 @@
1
+ from typing import Type, Optional
2
+ import structlog
3
+
4
+ from django.conf import settings
5
+ from django.contrib.auth import get_user_model
6
+ from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
7
+ from django.core.exceptions import PermissionDenied
8
+ from django.http import HttpRequest
9
+ from django.utils.translation import gettext_lazy as _
10
+
11
+ from ninja_jwt.exceptions import AuthenticationFailed, InvalidToken, TokenError
12
+ from ninja_jwt.settings import api_settings
13
+ from ninja_jwt.tokens import Token
14
+ from oxutils.constants import ACCESS_TOKEN_COOKIE
15
+ from ninja.utils import check_csrf
16
+
17
+
18
+ logger = structlog.get_logger(__name__)
19
+
20
+
21
+
22
+
23
+ class JWTAuthBaseMiddleware:
24
+ """
25
+ Base middleware for JWT authentication.
26
+ Handles token validation and user authentication.
27
+ """
28
+
29
+ def __init__(self, get_response):
30
+ self.get_response = get_response
31
+ self.user_model = get_user_model()
32
+
33
+ def __call__(self, request: HttpRequest):
34
+ """
35
+ Process the request through the middleware.
36
+ """
37
+ # Process request before view
38
+ self.process_request(request)
39
+
40
+ # Get response from next middleware/view
41
+ response = self.get_response(request)
42
+
43
+ return response
44
+
45
+ def get_token_from_request(self, request: HttpRequest) -> Optional[str]:
46
+ """
47
+ Extract JWT token from request.
48
+ Must be implemented by subclasses.
49
+
50
+ Returns:
51
+ Token string if found, None otherwise
52
+ """
53
+ raise NotImplementedError(
54
+ "Subclasses must implement get_token_from_request() method"
55
+ )
56
+
57
+ def process_request(self, request: HttpRequest):
58
+ """
59
+ Process request and validate JWT token if present.
60
+ Authentication is handled by another service - this only validates token signature and claims.
61
+
62
+ Skips authentication if user is already authenticated by a previous middleware.
63
+ """
64
+ # Skip if user is already authenticated by another middleware
65
+ if hasattr(request, 'user') and request.user.is_authenticated:
66
+ if settings.DEBUG:
67
+ logger.debug(
68
+ "jwt_auth_skipped",
69
+ description="User already authenticated, skipping JWT middleware",
70
+ user_id=getattr(request.user, 'id', None),
71
+ path=request.path
72
+ )
73
+ return
74
+
75
+ try:
76
+ token = self.get_token_from_request(request)
77
+ except PermissionDenied as e:
78
+ # CSRF or other security check failed
79
+ logger.warning(
80
+ "security_check_failed",
81
+ description="Security check failed during token extraction",
82
+ exception=type(e).__name__,
83
+ error=str(e),
84
+ path=request.path,
85
+ remote_addr=request.META.get('REMOTE_ADDR')
86
+ )
87
+ request.user = AnonymousUser()
88
+ return
89
+
90
+ if token:
91
+ try:
92
+ # Only validate token and extract user info (no DB lookup)
93
+ self.jwt_authenticate(request, token)
94
+ except (InvalidToken, AuthenticationFailed) as e:
95
+ # Token invalid - set anonymous user
96
+ if settings.DEBUG:
97
+ logger.debug(
98
+ "jwt_validation_failed",
99
+ description="JWT validation failed",
100
+ exception=type(e).__name__,
101
+ path=request.path
102
+ )
103
+ request.user = AnonymousUser()
104
+ else:
105
+ # No token provided - anonymous user
106
+ request.user = AnonymousUser()
107
+
108
+ @classmethod
109
+ def get_validated_token(cls, raw_token) -> Type[Token]:
110
+ """
111
+ Validates an encoded JSON web token and returns a validated token wrapper object.
112
+ Only validates signature and claims - authentication is handled by external service.
113
+ """
114
+ messages = []
115
+ for AuthToken in api_settings.AUTH_TOKEN_CLASSES:
116
+ try:
117
+ return AuthToken(raw_token)
118
+ except TokenError as e:
119
+ messages.append(
120
+ {
121
+ "token_class": AuthToken.__name__,
122
+ "token_type": AuthToken.token_type,
123
+ "message": e.args[0],
124
+ }
125
+ )
126
+
127
+ raise InvalidToken(
128
+ {
129
+ "detail": _("Given token not valid for any token type"),
130
+ "messages": messages,
131
+ }
132
+ )
133
+
134
+ def get_user(self, validated_token) -> AbstractBaseUser:
135
+ """
136
+ Returns a stateless user object from the validated token.
137
+ No database lookup - authentication is handled by external service.
138
+ """
139
+ try:
140
+ user_id = validated_token[api_settings.USER_ID_CLAIM]
141
+ except KeyError as e:
142
+ raise InvalidToken(
143
+ _("Token contained no recognizable user identification")
144
+ ) from e
145
+
146
+ # Validate user_id type and value
147
+ if not isinstance(user_id, (int, str)) or not user_id:
148
+ raise InvalidToken(_("Invalid user identification format"))
149
+
150
+ # Additional validation for string user_id
151
+ if isinstance(user_id, str) and len(user_id) > 255:
152
+ raise InvalidToken(_("User identification too long"))
153
+
154
+ # Return stateless TokenUser (no DB lookup)
155
+ return api_settings.TOKEN_USER_CLASS(validated_token)
156
+
157
+ def jwt_authenticate(self, request: HttpRequest, token: str) -> AbstractBaseUser:
158
+ """
159
+ Authenticate user from JWT token and attach to request.
160
+ """
161
+ validated_token = self.get_validated_token(token)
162
+ user = self.get_user(validated_token)
163
+ request.user = user
164
+ request.token_user = user # For backward compatibility and request.user can be overridden by other middlewares
165
+ return user
166
+
167
+
168
+ class JWTHeaderAuthMiddleware(JWTAuthBaseMiddleware):
169
+ """
170
+ JWT authentication middleware that extracts token from Authorization header.
171
+ Stateless authentication without database lookup.
172
+ """
173
+ openapi_scheme: str = "bearer"
174
+ header: str = "Authorization"
175
+
176
+
177
+ def get_token_from_request(self, request: HttpRequest) -> str:
178
+ """
179
+ Extract JWT token from Authorization header.
180
+ Validates scheme and format without logging sensitive data.
181
+ """
182
+ headers = request.headers
183
+ auth_value = headers.get(self.header)
184
+ if not auth_value:
185
+ return None
186
+
187
+ parts = auth_value.split(" ")
188
+
189
+ # Validate minimum parts
190
+ if len(parts) < 2:
191
+ if settings.DEBUG:
192
+ logger.warning(
193
+ "invalid_authorization_header",
194
+ description="Invalid Authorization header format",
195
+ remote_addr=request.META.get('REMOTE_ADDR')
196
+ )
197
+ return None
198
+
199
+ # Validate scheme
200
+ if parts[0].lower() != self.openapi_scheme:
201
+ if settings.DEBUG:
202
+ logger.warning(
203
+ "unexpected_auth_scheme",
204
+ description="Unexpected auth scheme",
205
+ expected_scheme=self.openapi_scheme,
206
+ actual_scheme=parts[0],
207
+ remote_addr=request.META.get('REMOTE_ADDR')
208
+ )
209
+ return None
210
+
211
+ token = " ".join(parts[1:])
212
+
213
+ # Basic token format validation (JWT has 3 parts separated by dots)
214
+ if token.count('.') != 2:
215
+ if settings.DEBUG:
216
+ logger.warning(
217
+ "invalid_jwt_format",
218
+ description="Invalid JWT format",
219
+ remote_addr=request.META.get('REMOTE_ADDR')
220
+ )
221
+ return None
222
+
223
+ return token
224
+
225
+
226
+ class JWTCookieAuthMiddleware(JWTAuthBaseMiddleware):
227
+ """
228
+ JWT authentication middleware that extracts token from cookies.
229
+ Stateless authentication without database lookup.
230
+ """
231
+ param_name = ACCESS_TOKEN_COOKIE
232
+
233
+ def get_token_from_request(self, request: HttpRequest) -> Optional[str]:
234
+ """
235
+ Extract JWT token from cookies with CSRF protection.
236
+
237
+ CSRF check is required for cookie-based authentication to prevent
238
+ cross-site request forgery attacks.
239
+
240
+ Override to customize cookie name or CSRF behavior.
241
+
242
+ Raises:
243
+ PermissionDenied: If CSRF check fails
244
+ """
245
+ # CSRF protection for cookie-based auth
246
+ error_response = check_csrf(request)
247
+ if error_response:
248
+ logger.warning(
249
+ "csrf_check_failed",
250
+ description="CSRF validation failed for cookie-based JWT auth",
251
+ path=request.path,
252
+ remote_addr=request.META.get('REMOTE_ADDR'),
253
+ cookie_name=self.param_name
254
+ )
255
+ raise PermissionDenied("CSRF check failed")
256
+
257
+ return request.COOKIES.get(self.param_name, None)
258
+
259
+
260
+ class BasicNoPasswordAuthMiddleware:
261
+ """
262
+ DEVELOPMENT ONLY: Basic authentication middleware without password verification.
263
+
264
+ WARNING: This middleware bypasses password authentication and should ONLY be used
265
+ in development environments. It allows authentication by providing only a username/email
266
+ in the Authorization header using Basic auth format.
267
+
268
+ This middleware automatically disables itself when settings.DEBUG is False.
269
+
270
+ Usage:
271
+ Authorization: Basic base64(username:)
272
+ or
273
+ Authorization: Basic base64(username:anything)
274
+
275
+ Example:
276
+ # For user "admin@example.com"
277
+ # Base64 encode: "admin@example.com:"
278
+ # Header: Authorization: Basic YWRtaW5AZXhhbXBsZS5jb206
279
+
280
+ IMPORTANT: Remove this middleware before deploying to production!
281
+ """
282
+
283
+ header = "Authorization"
284
+ scheme = "basic"
285
+
286
+ def __init__(self, get_response):
287
+ self.get_response = get_response
288
+ self.user_model = get_user_model()
289
+
290
+ # Check if in debug mode - disable completely if not
291
+ self._enabled = settings.DEBUG
292
+
293
+ if self._enabled:
294
+ # Warning log on initialization
295
+ logger.warning(
296
+ "insecure_middleware_loaded",
297
+ description="BasicNoPasswordAuthMiddleware loaded - THIS IS INSECURE AND FOR DEVELOPMENT ONLY",
298
+ middleware=self.__class__.__name__
299
+ )
300
+ else:
301
+ # Log that middleware is disabled
302
+ logger.info(
303
+ "insecure_middleware_disabled",
304
+ description="BasicNoPasswordAuthMiddleware disabled in non-DEBUG mode",
305
+ middleware=self.__class__.__name__
306
+ )
307
+
308
+ def __call__(self, request: HttpRequest):
309
+ """
310
+ Process the request through the middleware.
311
+ If not in DEBUG mode, simply passes through without any processing.
312
+ """
313
+ if not self._enabled:
314
+ return self.get_response(request)
315
+
316
+ self.process_request(request)
317
+ return self.get_response(request)
318
+
319
+ def process_request(self, request: HttpRequest):
320
+ """
321
+ Process request and authenticate user if Basic auth header is present.
322
+ Skips if user is already authenticated.
323
+ Note: This method is only called when DEBUG is True.
324
+ """
325
+ # Skip if already authenticated
326
+ if hasattr(request, 'user') and request.user.is_authenticated:
327
+ return
328
+
329
+ auth_header = request.headers.get(self.header)
330
+ if not auth_header:
331
+ return
332
+
333
+ try:
334
+ user = self._authenticate(auth_header)
335
+ if user:
336
+ request.user = user
337
+ logger.info(
338
+ "dev_auth_success",
339
+ description="Development authentication successful",
340
+ user_id=user.id,
341
+ username=user.email,
342
+ path=request.path
343
+ )
344
+ except Exception as e:
345
+ logger.debug(
346
+ "dev_auth_failed",
347
+ description="Development authentication failed",
348
+ error=str(e),
349
+ path=request.path
350
+ )
351
+
352
+ def _authenticate(self, auth_header: str) -> Optional[AbstractBaseUser]:
353
+ """
354
+ Extract credentials from Basic auth header and authenticate user without password.
355
+
356
+ Args:
357
+ auth_header: The Authorization header value
358
+
359
+ Returns:
360
+ User object if found and active, None otherwise
361
+ """
362
+ parts = auth_header.split(" ")
363
+
364
+ # Validate scheme
365
+ if len(parts) != 2 or parts[0].lower() != self.scheme:
366
+ return None
367
+
368
+ # Decode base64 credentials
369
+ import base64
370
+ try:
371
+ encoded_credentials = parts[1]
372
+ decoded_bytes = base64.b64decode(encoded_credentials)
373
+ decoded_credentials = decoded_bytes.decode('utf-8')
374
+ except Exception:
375
+ logger.debug("Failed to decode base64 credentials")
376
+ return None
377
+
378
+ # Split username:password (password is ignored)
379
+ if ':' in decoded_credentials:
380
+ username, _ = decoded_credentials.split(':', 1)
381
+ else:
382
+ username = decoded_credentials
383
+
384
+ if not username:
385
+ return None
386
+
387
+ # Find user by email or username
388
+ try:
389
+ user = self.user_model.objects.get(email=username)
390
+ except self.user_model.DoesNotExist:
391
+ try:
392
+ # Try by username field if different from email
393
+ user = self.user_model.objects.get(**{self.user_model.USERNAME_FIELD: username})
394
+ except (self.user_model.DoesNotExist, AttributeError):
395
+ logger.debug(f"User not found: {username}")
396
+ return None
397
+
398
+ # Check if user is active
399
+ if not user.is_active:
400
+ logger.debug(f"User {username} is inactive")
401
+ return None
402
+
403
+ return user
404
+
oxutils/jwt/models.py CHANGED
@@ -11,23 +11,49 @@ from .tokens import OrganizationAccessToken
11
11
  logger = structlog.get_logger(__name__)
12
12
 
13
13
 
14
+ class TenantUser:
15
+ def __init__(
16
+ self,
17
+ oxi_id: str | None = None,
18
+ id: str | None = None,
19
+ is_owner: bool = False,
20
+ is_admin: bool = False,
21
+ status: str | None = None,
22
+ ):
23
+ self.oxi_id = oxi_id
24
+ self.id = id
25
+ self.is_owner = is_owner
26
+ self.is_admin = is_admin
27
+ self.status = status
28
+
29
+ def __bool__(self):
30
+ return self.status == 'active'
31
+
32
+ def is_active(self):
33
+ return self.status == 'active'
34
+
35
+
14
36
  class TokenTenant:
15
37
 
16
38
  def __init__(
17
39
  self,
18
40
  schema_name: str,
19
- tenant_id: int,
41
+ tenant_id: str,
20
42
  oxi_id: str,
21
43
  subscription_plan: str,
22
44
  subscription_status: str,
23
- status: str,
45
+ subscription_end_date: str | None = None,
46
+ status: str = 'active',
47
+ user: TenantUser | None = None,
24
48
  ):
25
49
  self.schema_name = schema_name
26
50
  self.id = tenant_id
27
51
  self.oxi_id = oxi_id
28
52
  self.subscription_plan = subscription_plan
29
53
  self.subscription_status = subscription_status
54
+ self.subscription_end_date = subscription_end_date
30
55
  self.status = status
56
+ self.user = user
31
57
 
32
58
  def __str__(self):
33
59
  return f"{self.schema_name} - {self.oxi_id}"
@@ -44,23 +70,93 @@ class TokenTenant:
44
70
  def is_deleted(self):
45
71
  return self.status == 'deleted'
46
72
 
73
+ def __bool__(self):
74
+ return self.status == 'active'
75
+
76
+ @property
77
+ def is_admin_user(self):
78
+ if self.user:
79
+ return self.user.is_admin
80
+ return False
81
+
82
+ @property
83
+ def is_owner_user(self):
84
+ if self.user:
85
+ return self.user.is_owner
86
+ return False
87
+
88
+ @property
89
+ def is_tenant_user(self):
90
+ if self.user:
91
+ return self.user.is_active()
92
+ return False
93
+
94
+ def get_tenant_type(self):
95
+ return self.subscription_status
96
+
47
97
  @classmethod
48
98
  def for_token(cls, token):
49
99
  try:
50
100
  token_obj = OrganizationAccessToken(token=token)
101
+
102
+ # set the tenant user
103
+ if 'tenant_user_id' in token_obj and token_obj.get('tenant_user_id'):
104
+ user = TenantUser(
105
+ oxi_id=token_obj.get('tenant_user_oxi_id'),
106
+ id=token_obj.get('tenant_user_id'),
107
+ is_owner=token_obj.get('tenant_user_is_owner'),
108
+ is_admin=token_obj.get('tenant_user_is_admin'),
109
+ status=token_obj.get('tenant_user_status'),
110
+ )
111
+ else:
112
+ user = TenantUser()
113
+
51
114
  tenant = cls(
52
115
  schema_name=token_obj['schema_name'],
53
116
  tenant_id=token_obj['tenant_id'],
54
117
  oxi_id=token_obj['oxi_id'],
55
118
  subscription_plan=token_obj['subscription_plan'],
56
119
  subscription_status=token_obj['subscription_status'],
120
+ subscription_end_date=token_obj.get('subscription_end_date'),
57
121
  status=token_obj['status'],
122
+ user=user,
58
123
  )
124
+
59
125
  return tenant
60
126
  except Exception:
61
127
  logger.exception('Failed to create TokenTenant from token', token=token)
62
128
  return None
63
129
 
130
+ @classmethod
131
+ def from_db(cls, tenant) -> 'TokenTenant':
132
+ if not tenant:
133
+ raise ValueError('Tenant is required')
134
+
135
+ if hasattr(tenant, 'user') and tenant.user:
136
+ user = TenantUser(
137
+ oxi_id=tenant.user.user.oxi_id,
138
+ id=tenant.user.id,
139
+ is_owner=tenant.user.is_owner,
140
+ is_admin=tenant.user.is_admin,
141
+ status=tenant.user.status,
142
+ )
143
+ else:
144
+ user = TenantUser()
145
+
146
+ return cls(
147
+ schema_name=tenant.schema_name,
148
+ tenant_id=tenant.id,
149
+ oxi_id=tenant.oxi_id,
150
+ subscription_plan=tenant.subscription_plan,
151
+ subscription_status=tenant.subscription_status,
152
+ subscription_end_date=tenant.subscription_end_date,
153
+ status=tenant.status,
154
+ user=user,
155
+ )
156
+
157
+ def __repr__(self):
158
+ return f"TokenTenant(schema_name='{self.schema_name}', oxi_id='{self.oxi_id}', status='{self.status}')"
159
+
64
160
 
65
161
  class TokenUser(DefaultTonkenUser):
66
162
  @cached_property
oxutils/jwt/tokens.py CHANGED
@@ -58,6 +58,14 @@ class OrganizationAccessToken(Token):
58
58
  token.payload['subscription_end_date'] = str(tenant.subscription_end_date)
59
59
  token.payload['status'] = str(tenant.status)
60
60
 
61
+ # Add tenant user info if available
62
+ if hasattr(tenant, 'user'):
63
+ token.payload['tenant_user_oxi_id'] = str(tenant.user.user.oxi_id)
64
+ token.payload['tenant_user_id'] = str(tenant.user.id)
65
+ token.payload['tenant_user_is_owner'] = tenant.user.is_owner
66
+ token.payload['tenant_user_is_admin'] = tenant.user.is_admin
67
+ token.payload['tenant_user_status'] = str(tenant.user.status)
68
+
61
69
  return token
62
70
 
63
71
  @property
@@ -1,10 +1,13 @@
1
1
  from django.conf import settings
2
+ from django.core.exceptions import ObjectDoesNotExist
2
3
  from django.db import connection
3
4
  from django.http import Http404
4
5
  from django.urls import set_urlconf
5
6
  from django.utils.module_loading import import_string
6
7
  from django.utils.deprecation import MiddlewareMixin
7
8
 
9
+ import structlog
10
+
8
11
  from django_tenants.utils import (
9
12
  get_public_schema_name,
10
13
  get_public_schema_urlconf,
@@ -23,6 +26,9 @@ from oxutils.oxiliere.context import set_current_tenant_schema_name
23
26
 
24
27
 
25
28
 
29
+ logger = structlog.get_logger(__name__)
30
+
31
+
26
32
 
27
33
  class TenantMainMiddleware(MiddlewareMixin):
28
34
  TENANT_NOT_FOUND_EXCEPTION = Http404
@@ -44,6 +50,22 @@ class TenantMainMiddleware(MiddlewareMixin):
44
50
  """
45
51
  return tenant_model.objects.get(oxi_id=oxi_id)
46
52
 
53
+ def get_tenant_user(self, tenant, user, raise_exception=False):
54
+ """ Get tenant user by tenant and user.
55
+ """
56
+ if not tenant or not user:
57
+ if raise_exception:
58
+ raise ObjectDoesNotExist("tenant_user_not_found, tenant or user is None")
59
+ return None
60
+
61
+ try:
62
+ return tenant.users.select_related('user').get(user__pk=user.id)
63
+ except ObjectDoesNotExist:
64
+ logger.error("tenant_user_not_found", tenant_id=tenant.id, user_id=user.id)
65
+ if raise_exception:
66
+ raise ObjectDoesNotExist("tenant_user_not_found")
67
+ return None
68
+
47
69
  def process_request(self, request):
48
70
  # Connection needs first to be at the public schema, as this is where
49
71
  # the tenant metadata is stored.
@@ -51,42 +73,61 @@ class TenantMainMiddleware(MiddlewareMixin):
51
73
  connection.set_schema_to_public()
52
74
 
53
75
  oxi_id = self.get_org_id_from_request(request)
76
+ tenant_model = connection.tenant_model
54
77
 
55
78
  # Try to get tenant from cookie token first
56
79
  tenant_token = request.COOKIES.get(ORGANIZATION_TOKEN_COOKIE_KEY)
57
80
  tenant = None
81
+ old_tenant = None
58
82
  request._should_set_tenant_cookie = False
59
83
 
60
84
  if tenant_token:
61
85
  tenant = TokenTenant.for_token(tenant_token)
62
86
  # Verify the token's oxi_id matches the request
63
87
  if not is_system_tenant(tenant) and tenant.oxi_id != oxi_id:
88
+ logger.info("tenant_token_oxi_id_doesnt_match_request_oxi_id", tenant_oxi_id=tenant.oxi_id, request_oxi_id=oxi_id)
89
+ old_tenant = tenant
64
90
  tenant = None
65
91
 
66
92
  # If no valid token, fetch from database
67
93
  if not tenant:
68
94
  if oxi_id: # fetch with oxi_id on tenant
69
- tenant_model = connection.tenant_model
70
95
  try:
71
96
  tenant = self.get_tenant(tenant_model, oxi_id)
97
+ tenant.user = self.get_tenant_user(tenant, request.user, raise_exception=True)
98
+
72
99
  # Mark that we need to set the cookie in the response
73
100
  request._should_set_tenant_cookie = True
74
- except tenant_model.DoesNotExist:
101
+
102
+ if old_tenant:
103
+ logger.info("tenant_changed", old_tenant=old_tenant.oxi_id, new_tenant=tenant.oxi_id)
104
+
105
+ except ObjectDoesNotExist as ex:
106
+ logger.error("tenant_not_found", oxi_id=oxi_id, error=str(ex))
75
107
  default_tenant = self.no_tenant_found(request, oxi_id)
76
108
  return default_tenant
77
109
  else: # try to return the system tenant
78
110
  try:
79
111
  from oxutils.oxiliere.caches import get_system_tenant
80
112
  tenant = get_system_tenant()
113
+ tenant.user = self.get_tenant_user(tenant, request.user, raise_exception=False)
81
114
  request._should_set_tenant_cookie = True
82
115
  except Exception as e:
116
+ logger.error("system_tenant_not_found", error=str(e))
83
117
  from django.http import HttpResponseBadRequest
84
118
  return HttpResponseBadRequest('Missing X-Organization-ID header')
85
119
 
86
120
  if tenant.is_deleted or not tenant.is_active:
121
+ logger.error("tenant_is_deleted_or_inactive", oxi_id=oxi_id)
87
122
  return self.no_tenant_found(request, oxi_id)
88
123
 
89
- request.tenant = tenant
124
+ if tenant and not isinstance(tenant, TokenTenant):
125
+ request.db_tenant = tenant
126
+ else:
127
+ request.db_tenant = None
128
+
129
+ request.tenant = TokenTenant.from_db(tenant)
130
+
90
131
  set_current_tenant_schema_name(tenant.schema_name)
91
132
  connection.set_tenant(request.tenant)
92
133
  self.setup_url_routing(request)
@@ -94,9 +135,9 @@ class TenantMainMiddleware(MiddlewareMixin):
94
135
  def process_response(self, request, response):
95
136
  """Set the tenant token cookie if needed."""
96
137
  if hasattr(request, '_should_set_tenant_cookie') and request._should_set_tenant_cookie:
97
- if hasattr(request, 'tenant') and not isinstance(request.tenant, TokenTenant):
138
+ if hasattr(request, 'db_tenant') and isinstance(request.db_tenant, connection.tenant_model):
98
139
  # Generate token from DB tenant
99
- token = OrganizationAccessToken.for_tenant(request.tenant)
140
+ token = OrganizationAccessToken.for_tenant(request.db_tenant)
100
141
  response.set_cookie(
101
142
  key=ORGANIZATION_TOKEN_COOKIE_KEY,
102
143
  value=str(token),
@@ -1,81 +1,95 @@
1
+ import structlog
2
+
1
3
  from ninja_extra.permissions import BasePermission
2
- from oxutils.oxiliere.utils import get_tenant_user_model
3
4
  from oxutils.constants import OXILIERE_SERVICE_TOKEN
4
5
  from oxutils.jwt.tokens import OxilierServiceToken
6
+ from oxutils.jwt.models import TokenTenant
7
+
8
+
5
9
 
10
+ logger = structlog.get_logger(__name__)
6
11
 
7
12
 
8
- class TenantPermission(BasePermission):
13
+
14
+
15
+ class TenantBasePermission(BasePermission):
9
16
  """
10
17
  Vérifie que l'utilisateur a accès au tenant actuel.
11
18
  L'utilisateur doit être authentifié et avoir un lien avec le tenant.
12
19
  """
20
+ def check_tenant_permission(self, request) -> bool:
21
+ raise NotImplementedError("Subclasses must implement this method")
22
+
13
23
  def has_permission(self, request, **kwargs):
14
24
  if not request.user or not request.user.is_authenticated:
15
25
  return False
16
26
 
17
27
  if not hasattr(request, 'tenant'):
28
+ logger.warning('tenant_permission', type="tenant_not_found", user=request.user)
29
+ return False
30
+
31
+ if not isinstance(request.tenant, TokenTenant):
32
+ logger.warning(
33
+ 'tenant_permission',
34
+ type="tenant_is_not_token_tenant",
35
+ tenant=request.tenant,
36
+ user=request.user
37
+ )
18
38
  return False
19
-
20
- # Vérifier que l'utilisateur a accès à ce tenant
21
- return get_tenant_user_model().objects.filter(
22
- tenant__pk=request.tenant.pk,
23
- user__pk=request.user.pk
24
- ).exists()
25
39
 
40
+ return self.check_tenant_permission(request)
26
41
 
27
- class TenantOwnerPermission(BasePermission):
42
+
43
+ class TenantUserPermission(TenantBasePermission):
28
44
  """
29
- Vérifie que l'utilisateur est propriétaire (owner) du tenant actuel.
45
+ Vérifie que l'utilisateur est un membre du tenant actuel.
46
+ Alias de TenantPermission pour plus de clarté sémantique.
30
47
  """
31
- def has_permission(self, request, **kwargs):
32
- if not request.user or not request.user.is_authenticated:
33
- return False
34
-
35
- if not hasattr(request, 'tenant'):
36
- return False
48
+ def check_tenant_permission(self, request) -> bool:
49
+ tenant: TokenTenant = request.tenant
50
+
51
+ logger.info(
52
+ 'tenant_permission',
53
+ type="tenant_user_access_permission",
54
+ tenant=tenant, user=request.user,
55
+ passed=tenant.is_tenant_user
56
+ )
37
57
 
38
- return get_tenant_user_model().objects.filter(
39
- tenant__pk=request.tenant.pk,
40
- user__pk=request.user.pk,
41
- is_owner=True
42
- ).exists()
58
+ return tenant.is_tenant_user
43
59
 
44
60
 
45
- class TenantAdminPermission(BasePermission):
61
+ class TenantOwnerPermission(TenantBasePermission):
46
62
  """
47
- Vérifie que l'utilisateur est admin ou owner du tenant actuel.
63
+ Vérifie que l'utilisateur est propriétaire (owner) du tenant actuel.
48
64
  """
49
- def has_permission(self, request, **kwargs):
50
- if not request.user or not request.user.is_authenticated:
51
- return False
52
-
53
- if not hasattr(request, 'tenant'):
54
- return False
65
+ def check_tenant_permission(self, request) -> bool:
66
+ tenant: TokenTenant = request.tenant
67
+
68
+ logger.info(
69
+ 'tenant_permission',
70
+ type="tenant_user_access_permission",
71
+ tenant=tenant, user=request.user,
72
+ passed=tenant.is_owner_user
73
+ )
55
74
 
56
- return get_tenant_user_model().objects.filter(
57
- tenant__pk=request.tenant.pk,
58
- user__pk=request.user.pk,
59
- is_admin=True
60
- ).exists()
75
+ return tenant.is_owner_user
61
76
 
62
77
 
63
- class TenantUserPermission(BasePermission):
78
+ class TenantAdminPermission(TenantBasePermission):
64
79
  """
65
- Vérifie que l'utilisateur est un membre du tenant actuel.
66
- Alias de TenantPermission pour plus de clarté sémantique.
80
+ Vérifie que l'utilisateur est admin ou owner du tenant actuel.
67
81
  """
68
- def has_permission(self, request, **kwargs):
69
- if not request.user or not request.user.is_authenticated:
70
- return False
71
-
72
- if not hasattr(request, 'tenant'):
73
- return False
82
+ def check_tenant_permission(self, request) -> bool:
83
+ tenant: TokenTenant = request.tenant
84
+
85
+ logger.info(
86
+ 'tenant_permission',
87
+ type="tenant_user_access_permission",
88
+ tenant=tenant, user=request.user,
89
+ passed=tenant.is_admin_user
90
+ )
74
91
 
75
- return get_tenant_user_model().objects.filter(
76
- tenant__pk=request.tenant.pk,
77
- user__pk=request.user.pk
78
- ).exists()
92
+ return tenant.is_admin_user
79
93
 
80
94
 
81
95
  class OxiliereServicePermission(BasePermission):
@@ -95,3 +109,10 @@ class OxiliereServicePermission(BasePermission):
95
109
  return True
96
110
  except Exception:
97
111
  return False
112
+
113
+
114
+
115
+ IsTenantUser = TenantUserPermission()
116
+ IsTenantOwner = TenantOwnerPermission()
117
+ IsTenantAdmin = TenantAdminPermission()
118
+ IsOxiliereService = OxiliereServicePermission()
@@ -1,6 +1,9 @@
1
1
  from typing import Optional
2
2
  from uuid import UUID
3
3
  from ninja import Schema
4
+ from django.conf import settings
5
+ from django.core.exceptions import ImproperlyConfigured
6
+ from django.utils.module_loading import import_string
4
7
  from django.db import transaction
5
8
  from django.contrib.auth import get_user_model
6
9
  from django_tenants.utils import get_tenant_model
@@ -13,6 +16,18 @@ import structlog
13
16
  logger = structlog.get_logger(__name__)
14
17
 
15
18
 
19
+
20
+ def get_tenant_schema() -> 'TenantSchema':
21
+ if hasattr(settings, 'OX_TENANT_SCHEMA'):
22
+ try:
23
+ return import_string(settings.OX_TENANT_SCHEMA)
24
+ except ImportError as e:
25
+ raise ImproperlyConfigured(
26
+ f"Error: OX_TENANT_SCHEMA import error: {settings.OX_TENANT_SCHEMA}, please check your settings"
27
+ ) from e
28
+ return TenantSchema
29
+
30
+
16
31
  class TenantSchema(Schema):
17
32
  name: str
18
33
  oxi_id: str
@@ -29,6 +44,22 @@ class TenantOwnerSchema(Schema):
29
44
  email: str
30
45
 
31
46
 
47
+ class UserSchema(Schema):
48
+ oxi_id: UUID
49
+ first_name: Optional[str] = None
50
+ last_name: Optional[str] = None
51
+ email: str
52
+ is_active: bool
53
+ photo: Optional[str] = None
54
+
55
+
56
+ class TenantUser(Schema):
57
+ user: UserSchema
58
+ is_owner: bool
59
+ is_admin: bool
60
+ status: str
61
+
62
+
32
63
  class CreateTenantSchema(Schema):
33
64
  tenant: TenantSchema
34
65
  owner: TenantOwnerSchema
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oxutils
3
- Version: 0.1.14
3
+ Version: 0.1.15
4
4
  Summary: Production-ready utilities for Django applications in the Oxiliere ecosystem
5
5
  Keywords: django,utilities,jwt,audit,logging,celery,structlog
6
6
  Author: Edimedia Mutoke
@@ -1,4 +1,4 @@
1
- oxutils/__init__.py,sha256=4VMJ48vLu5PsZMfhvnI84bZ3Bvx7f1p2lQKUz6Boyuc,508
1
+ oxutils/__init__.py,sha256=DvkIn9bebbDFzHSk08CPBnzHDrEdursLTV4nkRrTGTI,508
2
2
  oxutils/apps.py,sha256=8pO8eXUZeKYn8fPo0rkoytmHACwDNuTNhdRcpkPTxGM,347
3
3
  oxutils/audit/__init__.py,sha256=uonc00G73Xm7RwRHVWD-wBn8lJYNCq3iBgnRGMWAEWs,583
4
4
  oxutils/audit/apps.py,sha256=xvnmB5Z6nLV7ejzhSeQbesTkwRoFygoPFob8H5QTHgU,304
@@ -33,9 +33,10 @@ oxutils/enums/invoices.py,sha256=E33QGQeutZUqvlovJY0VGDxWUb0i_kdfhEiir1ARKuQ,201
33
33
  oxutils/exceptions.py,sha256=CCjENOD0of6_noif2ajrpfbBLoG16DWa46iB9_uEe3M,3592
34
34
  oxutils/functions.py,sha256=4stHj94VebWX0s1XeWshubMD2v8w8QztTWppbkTE_Gg,3246
35
35
  oxutils/jwt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
- oxutils/jwt/auth.py,sha256=h3rm7nSEweMgyzy5HBRwqC1gPvZ-EZuwdJISSvnltXY,6349
37
- oxutils/jwt/models.py,sha256=Q0zRnWpK0trFoPDv5ZEY2ROCRaNn83W-K8SbrmSg1E8,2122
38
- oxutils/jwt/tokens.py,sha256=kWgtPl4XxV0xHkjbhd5QteQy8Wv5MsvyLcLVyO-gzuo,1822
36
+ oxutils/jwt/auth.py,sha256=3OACNYR6Mp5J57QUvu0iCuRuYiT5C49urUuCXqspNLA,7437
37
+ oxutils/jwt/middleware.py,sha256=m81lrpl9fJZ5gGHV_ysPxppd8pJklddFHDmxXRUpyvM,14285
38
+ oxutils/jwt/models.py,sha256=SG4qM2Ix7coopfIXUJ2KCkuC2bMAOuBnIwNhB5MVK14,5026
39
+ oxutils/jwt/tokens.py,sha256=l20XgPK-gUJcVwH8cWFSyYhfAQD70iqbzriRJkrPczo,2268
39
40
  oxutils/jwt/utils.py,sha256=Wuy-PnCcUw6MpY6z1Isy2vOx-_u1o6LjUfRJgf_cqbY,1202
40
41
  oxutils/locale/fr/LC_MESSAGES/django.po,sha256=APXt_8R99seCWjJyS5ELOawvRLvUqqBT32O252BaG5s,7971
41
42
  oxutils/logger/__init__.py,sha256=lhPCC8G1aHZTt-FWRqTWptVrqONln-ty9ufVDeOBHYs,183
@@ -66,11 +67,11 @@ oxutils/oxiliere/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMp
66
67
  oxutils/oxiliere/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
67
68
  oxutils/oxiliere/management/commands/grant_tenant_owners.py,sha256=U0tc-b677kEFA7KC6xah3Ufbg6qYkW21nRikC0FJRQI,774
68
69
  oxutils/oxiliere/management/commands/init_oxiliere_system.py,sha256=7ZmKOwL2TOIaPYBpGEoqcw2XslpG1VikUnTJwpu84Lo,4247
69
- oxutils/oxiliere/middleware.py,sha256=c0C1aalshhYNfe4SBglJkWfUo4Ct-d391GoWP_NhPOw,6412
70
+ oxutils/oxiliere/middleware.py,sha256=opwBbYMIyTMnytLQbQiKpcS5VzLblJci3iFc9r6X5aU,8142
70
71
  oxutils/oxiliere/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
71
72
  oxutils/oxiliere/models.py,sha256=dN3q-8W2gUcUra49b3R33o0ZNn3stc-pfkoVMAKR4gE,6260
72
- oxutils/oxiliere/permissions.py,sha256=6lJ_43a0-Z5O0B9cttA2cQFjZTGBucJk7w5rgtfd-lQ,3122
73
- oxutils/oxiliere/schemas.py,sha256=eV9MzkIpHFHmZFtv8Ck2xaGoVUDJXr-yl7w50U-Tj_8,2260
73
+ oxutils/oxiliere/permissions.py,sha256=Cz1GfwACxPMayQRVwGIMl_PlWM_f2ukP5VHeTlkmDM0,3566
74
+ oxutils/oxiliere/schemas.py,sha256=mjGyYTwQokAlMiZ0gKa5os_9HqFMp4NLaWpYxCb0yc0,3087
74
75
  oxutils/oxiliere/settings.py,sha256=ZuKppEyrucWxvvYC2-wLap4RzKfaEfaRdjJnsNZzpuY,440
75
76
  oxutils/oxiliere/signals.py,sha256=il6twTzbmv14SxukLx7dLw2QzuNDyVAsIgHmqbYspjw,97
76
77
  oxutils/oxiliere/tests.py,sha256=mrbGGRNg5jwbTJtWWa7zSKdDyeB4vmgZCRc2nk6VY-g,60
@@ -118,6 +119,6 @@ oxutils/users/models.py,sha256=RmPwsTbjhnDb0qALQrz_t-ODSj05NfISE9JHxcfv2z4,3178
118
119
  oxutils/users/tests.py,sha256=mrbGGRNg5jwbTJtWWa7zSKdDyeB4vmgZCRc2nk6VY-g,60
119
120
  oxutils/users/utils.py,sha256=jY-zL8vLT5U3E2FV3DqCvrPORjKLutbkPZTQ-z96dCw,376
120
121
  oxutils/utils.py,sha256=6yGX2d1ajU5RqgfqiaS4McYm7ip2KEgADABo3M-yA3U,595
121
- oxutils-0.1.14.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
122
- oxutils-0.1.14.dist-info/METADATA,sha256=UXHNH7j-hUIfeM_6Ti9VUJTY0BDlUC5k4HmiR3PE7_0,8389
123
- oxutils-0.1.14.dist-info/RECORD,,
122
+ oxutils-0.1.15.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
123
+ oxutils-0.1.15.dist-info/METADATA,sha256=_EDraKohaULS5QhUbtjTRhkka7-cFy-Jny-G-WMZr4E,8389
124
+ oxutils-0.1.15.dist-info/RECORD,,