Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 50 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.10] - 2024-12-30

### Added
- **Permissions System**: Complete RBAC (Role-Based Access Control) implementation
- `Role`, `Group`, `UserGroup` models for role management
- `RoleGrant` for role-based permission templates
- `Grant` model for user-specific permissions with context support
- Action expansion system for permission wildcards
- PostgreSQL GIN indexes for efficient permission queries
- Group-specific role grants for fine-grained control
- **Oxiliere Multi-Tenant Enhancements**:
- `BaseTenant` and `BaseTenantUser` abstract models
- Soft delete support with `delete_tenant()` and `restore()` methods
- Tenant status management with `TenantStatus` enum
- User management with `add_user()` and `remove_user()` methods
- Tenant signals: `tenant_user_added`, `tenant_user_removed`
- Schema name generation utilities
- System tenant protection
- Authorization middleware and checks
- **JWT Enhancements**:
- Extended JWT authentication with permission support
- Token user model for JWT-based user representation
- Improved JWT models and schemas
- **Model Improvements**:
- New field types in `oxutils.models.fields`
- Enhanced base model mixins
- Better timestamp and tracking support
- **User Management**:
- Enhanced user models with tenant integration
- User permission management

### Changed
- **BREAKING**: Removed `django-cid` dependency in favor of `django-structlog`'s built-in request_id
- `request_id` is now automatically generated by `django-structlog.middlewares.RequestMiddleware`
- Removed `cid.middleware.CidMiddleware` from `AUDIT_MIDDLEWARE`
- Removed `cid.apps.CidAppConfig` from `UTILS_APPS`
- Auditlog now uses `request_id` from django-structlog via `oxutils.audit.utils.get_request_id()`
- The `cid` field in auditlog entries now contains the `request_id` for correlation
- **Test Suite Reorganization**: Tests restructured by module with isolated settings
- `tests/common/` for common tests
- `tests/oxiliere/` for multi-tenant tests
- `tests/permissions/` for permission tests
- Each module has its own `settings.py` for isolation
- Improved test documentation in `tests/README.md`
- **Middleware**: Enhanced tenant middleware with better error handling
- **Permissions**: Improved permission controllers and schemas
- **Dependencies**: Updated dependency versions in `pyproject.toml`

### Fixed
- Permission grant override now preserves actions in expanded form
- Improved error handling in tenant operations

### Migration Guide
If you were using `django-cid` directly:
- Replace `from cid.locals import get_cid` with `from oxutils.audit.utils import get_request_id`
- The `request_id` is now available in structlog context: `structlog.contextvars.get_contextvars().get('request_id')`
- Auditlog entries still use the `cid` field but it now contains the `request_id` from django-structlog
If upgrading from 0.1.9:
- Review new permission models and run migrations
- Update tenant models to inherit from `BaseTenant` and `BaseTenantUser`
- Update test imports if using test utilities

## [0.1.3] - 2024-12-08

Expand Down Expand Up @@ -84,6 +122,7 @@ If you were using `django-cid` directly:
- django-auditlog, django-structlog
- PyJWT, jwcrypto, cryptography

[Unreleased]: https://github.com/oxiliere/oxutils/compare/v0.1.3...HEAD
[Unreleased]: https://github.com/oxiliere/oxutils/compare/v0.1.10...HEAD
[0.1.10]: https://github.com/oxiliere/oxutils/compare/v0.1.9...v0.1.10
[0.1.3]: https://github.com/oxiliere/oxutils/compare/v0.1.0...v0.1.3
[0.1.0]: https://github.com/oxiliere/oxutils/releases/tag/v0.1.0
1 change: 0 additions & 1 deletion docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ Actions have dependencies that are automatically expanded:
- `r`: Read
- `w`: Write (implies `r`)
- `d`: Delete (implies `w`, `r`)
- `x`: Execute (implies `r`)

Example: Granting `['w']` automatically gives `['r', 'w']`

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "oxutils"
version = "0.1.9"
version = "0.1.10"
description = "Production-ready utilities for Django applications in the Oxiliere ecosystem"
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/oxutils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
- Permission management
"""

__version__ = "0.1.9"
__version__ = "0.1.10"

from oxutils.settings import oxi_settings
from oxutils.conf import UTILS_APPS, AUDIT_MIDDLEWARE
Expand Down
94 changes: 92 additions & 2 deletions src/oxutils/jwt/auth.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import os
from typing import Dict, Any, Optional, Type
from typing import Dict, Any, Optional, Type, Tuple
from django.utils.translation import gettext_lazy as _
from django.http import HttpRequest
from django.contrib.auth.models import AbstractUser
from django.contrib.auth import (
authenticate as django_authenticate,
login as django_login,
get_user_model
)
from jwcrypto import jwk
from django.core.exceptions import ImproperlyConfigured

from ninja.security.base import AuthBase
from ninja_jwt.authentication import (
JWTBaseAuthentication,
JWTStatelessUserAuthentication
)
from ninja.security import (
APIKeyCookie,
HttpBasicAuth,
)
from ninja_jwt.exceptions import InvalidToken
from ninja_jwt.settings import api_settings
Expand Down Expand Up @@ -81,7 +88,6 @@ def jwt_authenticate(self, request: HttpRequest, token: str) -> AbstractUser:
return token_user



class JWTAuth(AuthMixin, JWTStatelessUserAuthentication):
pass

Expand Down Expand Up @@ -110,5 +116,89 @@ def get_user(self, validated_token: Any) -> Type[AbstractUser]:
return api_settings.TOKEN_USER_CLASS(validated_token)


def authenticate_by_x_session_token(token: str) -> Optional[Tuple]:
"""
Copied from allauth.headless.internal.sessionkit, to "select_related"
"""
from allauth.headless import app_settings


session = app_settings.TOKEN_STRATEGY.lookup_session(token)
if not session:
return None
user_id_str = session.get(SESSION_KEY)
if user_id_str:
meta_pk = get_user_model()._meta.pk
if meta_pk:
user_id = meta_pk.to_python(user_id_str)
user = get_user_model().objects.filter(pk=user_id).first()
if user and user.is_active:
return (user, session)
return None


class XSessionTokenAuth(AuthBase):
"""
This security class uses the X-Session-Token that django-allauth
is using for authentication purposes.
"""

openapi_type: str = "apiKey"

def __call__(self, request: HttpRequest):
token = self.get_session_token(request)
if token:
user_session = authenticate_by_x_session_token(token)
if user_session:
return user_session[0]
return None

def get_session_token(self, request: HttpRequest) -> Optional[str]:
"""
Returns the session token for the given request, by looking up the
``X-Session-Token`` header. Override this if you want to extract the token
from e.g. the ``Authorization`` header.
"""
if request.session.session_key:
return request.session.session_key

return request.headers.get("X-Session-Token")


class BasicAuth(HttpBasicAuth):
def authenticate(self, request: HttpRequest, username: str, password: str) -> Optional[Any]:
user = django_authenticate(email=username, password=password)
if user and user.is_active:
django_login(request, user)
return user
return None


class BasicNoPasswordAuth(HttpBasicAuth):
def authenticate(self, request: HttpRequest, username: str, password: str) -> Optional[Any]:
try:
user = get_user_model().objects.get(email=username)
if user and user.is_active:
django_login(request, user)
return user
return None
except Exception as e:
return None

x_session_token_auth = XSessionTokenAuth()
basic_auth = BasicAuth()
basic_no_password_auth = BasicNoPasswordAuth()
jwt_auth = JWTAuth()
jwt_cookie_auth = JWTCookieAuth()




def get_auth_handlers(auths: List[AuthBase] = []) -> List[AuthBase]:
"""Auth handler switcher based on settings.DEBUG"""
from django.conf import settings

if settings.DEBUG:
return auths

return [jwt_auth, jwt_cookie_auth]
12 changes: 8 additions & 4 deletions src/oxutils/jwt/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ def __str__(self):
def pk(self):
return self.id

@property
def is_active(self):
return self.status == 'active'

@property
def is_deleted(self):
return self.status == 'deleted'

@classmethod
def for_token(cls, token):
try:
Expand Down Expand Up @@ -64,10 +72,6 @@ def oxi_id(self):
# for compatibility with the User model
return self.id

@property
def role(self):
return self.token.get('role', None)

@cached_property
def token_created_at(self):
return self.token.get('cat', None)
Expand Down
102 changes: 102 additions & 0 deletions src/oxutils/models/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import uuid
import time
from django.db import models
from django.db import transaction
from django.conf import settings
from oxutils.models.fields import MaskedBackupField



try:
from safedelete.models import SafeDeleteModel
from safedelete.models import SOFT_DELETE_CASCADE
from safedelete.signals import post_undelete
except ImportError:
from django.dispatch import Signal
post_undelete = Signal()
SOFT_DELETE_CASCADE = 2

class SafeDeleteModel(models.Model):
def __new__(cls, *args, **kwargs):
raise ImportError("django-safedelete is not installed, please install it to use SafeDeleteModel")


class UUIDPrimaryKeyMixin(models.Model):
Expand Down Expand Up @@ -114,3 +132,87 @@ class BaseModelMixin(UUIDPrimaryKeyMixin, TimestampMixin, ActiveMixin):
"""
class Meta:
abstract = True


class SafeDeleteModelMixin(SafeDeleteModel):
_safedelete_policy = SOFT_DELETE_CASCADE
mask_fields = []

_masked_backup = MaskedBackupField(default=dict, editable=False)

class Meta:
abstract = True

@transaction.atomic
def delete(self, *args, **kwargs):
backup = {}

for field_name in self.mask_fields:
field = self._meta.get_field(field_name)
old_value = getattr(self, field_name)

if old_value is None:
continue

backup[field_name] = old_value
masked = self._mask_value(field, old_value)
setattr(self, field_name, masked)

if backup:
self._masked_backup = backup
self.save(update_fields=[*backup.keys(), "_masked_backup"])

return super().delete(*args, **kwargs)

def _mask_value(self, field: models.Field, old_value):
uid = uuid.uuid4().hex
ts = int(time.time())

if isinstance(field, models.EmailField):
return f"{ts}.{uid}.deleted@invalid.local"

if isinstance(field, models.URLField):
return f"https://deleted.invalid/{ts}/{uid}"

if isinstance(field, models.SlugField):
return f"deleted-{ts}-{uid}"

if isinstance(field, models.CharField):
return f"__deleted__{ts}__{uid}"

if isinstance(field, models.IntegerField):
return None # souvent OK, sinon adapte

# fallback générique
return f"deleted-{ts}-{uid}"

@transaction.atomic
def restore_masked_fields(self):
if not self._masked_backup:
return

for field_name, old_value in self._masked_backup.items():
field = self._meta.get_field(field_name)

# vérification collision
qs = self.__class__._default_manager.filter(
**{field_name: old_value}
).exclude(pk=self.pk)

if qs.exists():
raise ValueError(
f"Collision détectée lors de la restauration du champ '{field_name}'"
)

setattr(self, field_name, old_value)

self._masked_backup = {}
self.save()


def _restore_masked_fields(sender, instance, **kwargs):
if isinstance(instance, SafeDeleteModelMixin):
instance.restore_masked_fields()


post_undelete.connect(_restore_masked_fields)
Loading
Loading