diff --git a/README.md b/README.md index be3d5a3..e52908b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/) [![Django 5.0+](https://img.shields.io/badge/django-5.0+-green.svg)](https://www.djangoproject.com/) [![Tests](https://img.shields.io/badge/tests-201%20passed-success.svg)](tests/) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![License](https://img.shields.io/badge/license-LGPL%203.0-blue.svg)](LICENSE) [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) ## Features @@ -21,6 +21,7 @@ - 💱 **Currency Module** - Multi-source exchange rates (BCC/OXR) - 📄 **PDF Generation** - WeasyPrint integration for Django - 🏢 **Multi-Tenant** - PostgreSQL schema-based isolation +- 🔑 **Permissions** — Domain-oriented RBAC with named actions, AND/OR logic, groups & grants, translatable labels --- @@ -108,6 +109,7 @@ TEMPLATES = [{ - **[Currency](docs/currency.md)** - Exchange rates management - **[PDF](docs/pdf.md)** - PDF generation with WeasyPrint - **[Oxiliere](docs/oxiliere.md)** - Multi-tenant architecture +- **[Permissions](docs/permissions.md)** - RBAC with named actions ## Requirements @@ -218,23 +220,74 @@ class InvoicePDFView(WeasyTemplateView): pdf_stylesheets = ['css/invoice.css'] ``` -### Multi-Tenant Setup +# Multi-Tenant Setup ```python # settings.py TENANT_MODEL = "oxiliere.Tenant" MIDDLEWARE = [ - 'oxutils.oxiliere.middleware.TenantMainMiddleware', # First! - # other middleware... + 'oxutils.oxiliere.middleware.TenantMainMiddleware', + # ... +] +``` + +### Permissions (v0.5.0) + +```python +# settings.py — define named actions & scopes with translatable labels +from django.utils.translation import gettext_lazy as _ + +PERMISSION_PRESET = { + "actions": { + "orders": { + "create": {"implies": [], "label": _("Create")}, + "approve": {"implies": ["create"], "label": _("Approve")}, + "cancel": {"implies": [], "label": _("Cancel")}, + }, + "articles": { + "read": {"implies": [], "label": _("Read")}, + "write": {"implies": ["read"], "label": _("Write")}, + "publish": {"implies": ["write"], "label": _("Publish")}, + }, + }, + "roles": [{"name": "Editor", "slug": "editor"}], + "groups": [], + "role_grants": [ + {"role": "editor", "scope": "articles", "actions": ["write"], "context": {}}, + ], +} + +# Scopes — strings or dicts with labels (recommended for frontend i18n) +ACCESS_SCOPES = [ + "articles", + {"key": "orders", "label": _("Orders")}, ] -# All requests must include X-Organization-ID header -# Data is automatically isolated per tenant schema +# Controller — AND (/) and OR (|) operators +from oxutils.permissions.perms import ScopePermission + +@api_controller('/orders', permissions=[ScopePermission('orders:create/approve')]) +class OrderController: # user needs create AND approve + ... + +# Frontend endpoint — translated action labels +# GET /api/access/scopes/orders/actions +# → {"scope": "orders", "actions": [ +# {"key": "create", "label": "Créer"}, +# {"key": "approve", "label": "Approuver"}, +# ]} + +# Frontend endpoint — translated scope labels +# GET /api/access/scopes +# → [ +# {"key": "articles", "label": "Articles"}, +# {"key": "orders", "label": "Commandes"}, +# ] ``` ## License -Apache 2.0 License - see [LICENSE](LICENSE) +LGPL 3.0 License - see [LICENSE](LICENSE) ## Support diff --git a/docs/permissions.md b/docs/permissions.md index 1235842..9ff1ff4 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -1,25 +1,25 @@ -# Permissions System +# Permissions System — v0.5.0 -**Flexible role-based access control with groups and custom grants** +**Domain-oriented role-based access control with named actions, groups and custom grants** + +> ⚠️ **Breaking change from 0.4.x** — actions are now **named strings** (e.g. `create`, `approve`) +> instead of single letters (`r`, `w`, `d`). The permission string format uses `/` (AND) +> and `|` (OR) separators. See [Migration from 0.4.x](#migration-from-04x) below. ## Features -- Role-based permissions with hierarchical actions -- Group management for bulk role assignment -- Custom grant overrides per user -- RoleGrant templates for role permissions -- Activate / deactivate grants per user without deleting them -- Application namespacing via `Role.app` -- **Auto-discovery**: each app exports its presets, scopes, and app name from a `permissions.py` module -- Automatic synchronization after changes -- Bulk operations for performance -- Full traceability with `created_by` tracking -- Context-based permission filtering +- **Named actions** — domain-oriented: `create`, `approve`, `cancel`, `publish`… +- **Translatable labels** — each action has a `label` field for i18n frontend display +- **Action hierarchy** — `approve` can imply `create`; declared in the preset via `implies` +- **AND / OR operators** — `/` = all actions required, `|` = at least one +- **Strict scope ownership** — each scope belongs to exactly one app +- Role-based permissions, groups, custom grant overrides, activate/deactivate +- Auto-discovery from app `permissions.py` modules +- Context-based filtering (multi-tenant ready) +- Full traceability with `created_by` and `locked` flags ## Setup -Add to `INSTALLED_APPS`: - ```python # settings.py INSTALLED_APPS = [ @@ -28,14 +28,49 @@ INSTALLED_APPS = [ ] ``` -Run migrations: - ```bash python manage.py migrate permissions ``` ## Core Concepts +### Action Definitions (NEW in 0.5.0) + +Actions are **named strings** with optional **translatable labels** and **hierarchy**, +defined per scope in `PERMISSION_PRESET["actions"]`: + +```python +from django.utils.translation import gettext_lazy as _ + +PERMISSION_PRESET = { + "actions": { + "orders": { + "create": {"implies": [], "label": _("Create")}, + "approve": {"implies": ["create"], "label": _("Approve")}, + "cancel": {"implies": [], "label": _("Cancel")}, + "refund": {"implies": ["approve"], "label": _("Refund")}, + "read": {"implies": [], "label": _("Read")}, + }, + "articles": { + "read": {"implies": [], "label": _("Read")}, + "write": {"implies": ["read"], "label": _("Write")}, + "publish": {"implies": ["write"], "label": _("Publish")}, + "archive": {"implies": ["publish"], "label": _("Archive")}, + }, + }, + "roles": [...], + "groups": [...], + "role_grants": [...], +} +``` + +- **`implies`** — actions automatically granted when this action is assigned. + `approve` implies `create` → granting `["approve"]` stores `["approve", "create"]`. +- **`label`** — translated display name for the frontend. Use `gettext_lazy` (`_()`) + for i18n. Falls back to the action key if omitted. +- **Scope ownership** — each scope is strictly owned by one app. Two apps defining + the same scope raises `ImproperlyConfigured`. + ### Architecture ``` @@ -44,221 +79,192 @@ User ──> UserGroup ──> Group ──> Role ──> RoleGrant └──────────> Grant <────────────────────────┘ ``` -### Models - -**Role**: Named set of permissions (e.g., `admin`, `editor`) -- `app`: optional namespace (e.g., `'blog'`, `'cms'`) — used to filter grants by application - -**Group**: Collection of roles for easier assignment (e.g., `staff`) - -**RoleGrant**: Permission template for a role on a scope -- Applies to all users with the role - -**Grant**: Effective user permission on a scope -- **Inherited**: `locked = False` (from RoleGrant, can be modified by group_sync) -- **Custom**: `locked = True` (after override, protected from group_sync) -- `is_active`: toggle grant on/off without deleting (default `True`) +### Models Summary -**UserGroup**: Links user to group for traceability - -### Actions Hierarchy - -Actions have dependencies that are automatically expanded: - -- `r`: Read -- `w`: Write (implies `r`) -- `d`: Delete (implies `w`, `r`) - -Example: Granting `['w']` automatically gives `['r', 'w']` +| Model | Description | +|---|---| +| **Role** | Named permission set (`admin`, `editor`). Optional `app` namespace. | +| **Group** | Collection of roles for bulk assignment (`staff`). | +| **RoleGrant** | Template: which actions a role has on a scope. Actions are expanded via hierarchy. | +| **Grant** | Effective user permission. `locked=False` = inherited, `locked=True` = custom. `is_active` toggle. | +| **UserGroup** | Links user to group for traceability. | ## Configuration -### Required Settings - ```python # settings.py -# Access manager configuration -ACCESS_MANAGER_SCOPE = "access" # Scope for access management endpoints -ACCESS_MANAGER_GROUP = "manager" # Group for UserGroup assignment in authorization (or None) -ACCESS_MANAGER_ROLE = "admin" # Role for permission check filtering (or None) -ACCESS_MANAGER_CONTEXT = {} # Additional context dict +ACCESS_MANAGER_SCOPE = "access" # optional, defaults to "access" +ACCESS_MANAGER_GROUP = "manager" # or None +ACCESS_MANAGER_ROLE = "manager" # or None +ACCESS_MANAGER_CONTEXT = {} + +# Scopes — strings (key only) or dicts with translatable labels +from django.utils.translation import gettext_lazy as _ -# List of valid scopes in your application ACCESS_SCOPES = [ - "access", + "articles", # plain string → label = key "users", - "articles", - "comments" + {"key": "orders", "label": _("Orders")}, # dict → translatable label + {"key": "invoices", "label": _("Invoices")}, ] -# Enable permission check caching (requires cacheops) CACHE_CHECK_PERMISSION = False - -# if cacheops is installed, enable caching -if 'cacheops' in settings.INSTALLED_APPS: - CACHE_CHECK_PERMISSION = True - -# and add "oxutils.permissions.*" in cacheops settings - -# Extra permission instances applied globally to all controllers -EXTRA_PERMISSIONS = [ - "myapp.permissions.IsPremium", - "myapp.permissions.IsVerified", -] ``` -### Auto-Discovery from Apps - -When ``PermissionsConfig.ready()`` runs (Django startup), it automatically -walks all installed apps and collects permission configuration from each -app's ``permissions.py`` module. No manual wiring needed — just drop a -``permissions.py`` in your app and export the relevant variables. - -**Discovered variables:** - -| Variable | Type | Description | -|---|---|---| -| `PERMISSION_PRESET` | `dict` | Roles, groups, and role grants defined by this app | -| `ACCESS_SCOPES` | `list[str]` | Scope names used by this app | -| `ACCESS_APPLICATION_NAME` | `str` | Application namespace (populates `ACCESS_APPLICATIONS`) | - -Each discovered entity (role, group, role_grant) automatically gets its -``app`` field set to the app's label, enabling application‑level filtering. +> 💡 **Recommendation**: always use the dict format with `_()` labels for scopes +> visible in the frontend. The `GET /api/access/scopes` endpoint returns +> `{"key": "...", "label": "..."}` pairs that can be displayed directly. -**Example — blog/permissions.py:** +### Full PERMISSION_PRESET Example ```python -# blog/permissions.py +from django.utils.translation import gettext_lazy as _ PERMISSION_PRESET = { + "actions": { + "access": { + "read": {"implies": [], "label": _("Read")}, + "write": {"implies": ["read"], "label": _("Write")}, + "delete": {"implies": ["read", "write"], "label": _("Delete")}, + "update": {"implies": ["read"], "label": _("Update")}, + }, + "orders": { + "create": {"implies": [], "label": _("Create")}, + "approve": {"implies": ["create"], "label": _("Approve")}, + "cancel": {"implies": [], "label": _("Cancel")}, + "read": {"implies": [], "label": _("Read")}, + }, + }, "roles": [ - {"name": "Author", "slug": "author"}, - {"name": "Commenter", "slug": "commenter"}, + {"name": "Manager", "slug": "manager"}, + {"name": "Editor", "slug": "editor"}, + {"name": "Viewer", "slug": "viewer"}, ], "groups": [ - {"name": "Blog Staff", "slug": "blog-staff", "roles": ["author"]}, + {"name": "Staff", "slug": "staff", "roles": ["editor", "viewer"]}, ], "role_grants": [ - {"role": "author", "scope": "posts", "actions": ["r", "w"]}, - {"role": "commenter", "scope": "comments", "actions": ["r", "w"]}, + {"role": "manager", "scope": "access", "actions": ["read", "write"], "context": {}}, + {"role": "editor", "scope": "articles", "actions": ["write"], "context": {}}, + {"role": "viewer", "scope": "articles", "actions": ["read"], "context": {}}, ], } - -ACCESS_SCOPES = ["posts", "comments"] - -ACCESS_APPLICATION_NAME = "blog" ``` -That's it — the preset, scopes, and application name are automatically merged -into the global configuration at startup. Load the merged preset with: +Load the preset: ```bash python manage.py load_permission_preset +python manage.py load_permission_preset --force ``` -### Permission Preset +### Auto-Discovery from Apps -Define initial permissions in settings: +Each app exports from `permissions.py`: ```python -# settings.py +# orders/permissions.py +from django.utils.translation import gettext_lazy as _ + PERMISSION_PRESET = { - "roles": [ - {"name": "Admin", "slug": "admin"}, - {"name": "Editor", "slug": "editor"}, - {"name": "Viewer", "slug": "viewer"} - ], - "group": [ - { - "name": "Staff", - "slug": "staff", - "roles": ["editor", "viewer"] + "actions": { + "orders": { + "create": {"implies": [], "label": _("Create")}, + "approve": {"implies": ["create"], "label": _("Approve")}, }, - { - "name": "Premium Staff", - "slug": "premium-staff", - "roles": ["editor"] - } + }, + "roles": [ + {"name": "Order Manager", "slug": "order-manager"}, ], "role_grants": [ - { - "role": "admin", - "scope": "users", - "actions": ["r", "w", "d"], - "context": {} - }, - { - "role": "editor", - "scope": "articles", - "actions": ["r", "w"], - "context": {} - } - ] + {"role": "order-manager", "scope": "orders", + "actions": ["create", "approve"], "context": {}}, + ], } + +# Scopes with translatable labels for the frontend +ACCESS_SCOPES = [ + {"key": "orders", "label": _("Orders")}, +] + +ACCESS_APPLICATION_NAME = "orders" ``` -Load the preset: +## Permission String Format -```bash -python manage.py load_permission_preset +| Format | Meaning | +|---|---| +| `orders:create` | Single action | +| `orders:create/approve` | **AND** — must have `create` **and** `approve` | +| `orders:create\|approve` | **OR** — must have `create` **or** `approve` | +| `orders:create/approve:manager` | AND + role filter | +| `orders:create\|approve:manager` | OR + role filter | +| `orders:create/approve?tenant_id=42` | AND + context | + +## API Endpoints -# Force reload (careful with duplicates) -python manage.py load_permission_preset --force ``` +GET /api/access/scopes → list all scopes +GET /api/access/scopes/{scope}/actions → actions with translated labels -## API Endpoints +GET /api/access/roles → list roles +GET /api/access/groups → list groups +POST /api/access/groups → create group +PUT /api/access/groups/{slug} → update group +DELETE /api/access/groups/{slug} → delete group -All endpoints are prefixed with `/api/access/` (configurable in router). +POST /api/access/users/assign-role → assign role to user +POST /api/access/users/revoke-role → revoke role +POST /api/access/users/assign-group → assign group +POST /api/access/users/revoke-group → revoke group +POST /api/access/users/override-grant → override grant -### Roles +GET /api/access/users/{id}/grants → user grants +GET /api/access/users/{id}/groups → user groups -```http -GET /api/access/roles # List all roles -POST /api/access/roles # Create role -GET /api/access/roles/{slug} # Get role details -PUT /api/access/roles/{slug} # Update role -DELETE /api/access/roles/{slug} # Delete role +GET /api/access/role-grants → list role grants +POST /api/access/role-grants → create role grant +PUT /api/access/role-grants/{id} → update role grant +DELETE /api/access/role-grants/{id} → delete role grant +PUT /api/access/grants/{id} → update grant ``` -### Groups +### Scope Actions Endpoint (NEW in 0.5.0) ```http -GET /api/access/groups # List all groups -POST /api/access/groups # Create group -GET /api/access/groups/{slug} # Get group details -PUT /api/access/groups/{slug} # Update group -DELETE /api/access/groups/{slug} # Delete group -POST /api/access/groups/{slug}/sync # Sync group users +GET /api/access/scopes/orders/actions ``` -### User Assignment - -```http -POST /api/access/users/assign-role # Assign role to user -POST /api/access/users/revoke-role # Revoke role from user -POST /api/access/users/assign-group # Assign group to user -POST /api/access/users/revoke-group # Revoke group from user +```json +{ + "scope": "orders", + "actions": [ + {"key": "create", "label": "Créer"}, + {"key": "approve", "label": "Approuver"}, + {"key": "cancel", "label": "Annuler"}, + {"key": "read", "label": "Lire"} + ] +} ``` -### Grants +### Scopes Endpoint (NEW in 0.5.0) ```http -GET /api/access/grants # List grants -POST /api/access/grants # Create custom grant -PUT /api/access/grants/{id} # Update grant -DELETE /api/access/grants/{id} # Delete grant +GET /api/access/scopes ``` -### RoleGrants - -```http -GET /api/access/role-grants # List role grants -POST /api/access/role-grants # Create role grant -PUT /api/access/role-grants/{id} # Update role grant -DELETE /api/access/role-grants/{id} # Delete role grant +```json +[ + {"key": "orders", "label": "Commandes"}, + {"key": "articles", "label": "Articles"}, + {"key": "users", "label": "Utilisateurs"} +] ``` +> 💡 The `label` is resolved in the active locale — use `_()` in your `ACCESS_SCOPES` +> definitions to get translated scope names for free. + ## Usage ### Basic Permission Check @@ -266,870 +272,152 @@ DELETE /api/access/role-grants/{id} # Delete role grant ```python from oxutils.permissions.utils import check, str_check -# Simple check (ALL actions required) -if check(user, 'articles', ['r']): - # User can read articles - pass +# AND: all actions required +check(user, 'orders', ['create', 'approve']) # True if has both -# Check with context -if check(user, 'articles', ['w'], tenant_id=123): - # User can write articles for tenant 123 - pass +# String check — single action +str_check(user, 'orders:create') -# Check with role filter -if check(user, 'articles', ['w'], role='editor'): - # User can write articles via editor role - pass +# AND (must have both) +str_check(user, 'orders:create/approve') -# String-based check (convenient format) -if str_check(user, 'articles:r'): - # User can read articles - pass +# OR (at least one) +str_check(user, 'orders:create|approve') -# String check with role -if str_check(user, 'articles:w:editor'): - # User can write articles via editor role - pass - -# String check with context (query params) -if str_check(user, 'articles:w?tenant_id=123&status=published'): - # User can write published articles for tenant 123 - pass +# With role filter +str_check(user, 'orders:create/approve:manager') -# String check with role and context -if str_check(user, 'articles:w:editor?tenant_id=123'): - # User can write articles for tenant 123 via editor role - pass +# With context +str_check(user, 'orders:create?tenant_id=42') ``` -### "Any" Permission Checks (OR Logic) - -For checking if a user has **at least one** of multiple permissions: +### OR Checks ```python from oxutils.permissions.utils import any_action_check, any_permission_check -# Check if user has AT LEAST ONE action on a scope -if any_action_check(user, 'articles', ['r', 'w', 'd']): - # User has read OR write OR delete permission - pass - -# With role filter -if any_action_check(user, 'articles', ['w', 'd'], role='editor'): - # User has write OR delete via editor role - pass - -# With context -if any_action_check(user, 'articles', ['r', 'w'], tenant_id=123): - # User has read OR write for tenant 123 - pass - -# Check if user has AT LEAST ONE of multiple permissions -if any_permission_check( - user, - 'articles:r', # Can read articles - 'articles:w:editor', # OR can write as editor - 'invoices:d:admin' # OR can delete invoices as admin -): - # User has at least one of these permissions - pass +# OR on a single scope +any_action_check(user, 'orders', ['create', 'approve', 'cancel']) -# Complex example with different scopes and contexts -if any_permission_check( +# OR across different scopes/permissions +any_permission_check( user, - 'reports:r?department=finance', - 'reports:w:admin', - 'analytics:r' -): - # User can access if they have ANY of these permissions - pass + 'orders:create|approve', + 'articles:read', + 'users:read/write:admin', +) ``` -**Performance Note:** Both functions use a single optimized database query with OR conditions, regardless of how many permissions are checked. - ### Controller-Level Permissions -#### ScopePermission (AND Logic) - -Use `ScopePermission` to protect entire controllers or specific routes. User must have **ALL** specified actions: - ```python -from ninja_extra import api_controller, http_get -from oxutils.permissions.perms import ScopePermission - -# Protect entire controller -@api_controller('/articles', permissions=[ScopePermission('articles:w')]) -class ArticleController: - @http_get('/') - def list_articles(self): - # Only users with write permission on articles can access - pass - -# With role-specific permission -@api_controller('/admin', permissions=[ScopePermission('users:w:admin')]) -class AdminController: - pass - -# With context in permission string -@api_controller('/reports', permissions=[ScopePermission('reports:r?department=finance')]) -class ReportController: - pass - -# Method-level permission (override controller permission) -@api_controller('/articles') -class ArticleController: - @http_get('/', permissions=[ScopePermission('articles:r')]) - def list_articles(self): - # Read-only access - pass - - @http_post('/', permissions=[ScopePermission('articles:w')]) - def create_article(self): - # Write access required - pass -``` - -#### ScopeAnyActionPermission (OR Logic - Single Scope) - -Use when user needs **at least one** of multiple actions on a single scope: - -```python -from oxutils.permissions.perms import ScopeAnyActionPermission +from oxutils.permissions.perms import ( + ScopePermission, ScopeAnyActionPermission, ScopeAnyPermission +) -# User needs read OR write OR delete on articles -@api_controller('/articles', permissions=[ - ScopeAnyActionPermission('articles:rwd') -]) -class ArticleController: - # Access granted if user has ANY of: read, write, or delete +# AND — must have create AND approve +@api_controller('/orders', permissions=[ScopePermission('orders:create/approve')]) +class OrderController: pass -# With role filter -@api_controller('/reports', permissions=[ - ScopeAnyActionPermission('reports:rw:admin') -]) -class ReportController: - # User needs read OR write via admin role +# OR — must have create OR approve +@api_controller('/orders', permissions=[ScopePermission('orders:create|approve')]) +class FlexibleController: pass -# With context -@api_controller('/invoices', permissions=[ - ScopeAnyActionPermission('invoices:rwd?tenant_id=123') +# Always OR (ignores separator) +@api_controller('/orders', permissions=[ + ScopeAnyActionPermission('orders:create/approve/cancel') ]) -class InvoiceController: - # User needs read OR write OR delete for tenant 123 +class AnyController: pass -# With additional context via ctx parameter -@api_controller('/data', permissions=[ - ScopeAnyActionPermission('data:rw', ctx={'department': 'finance'}) -]) -class DataController: - pass -``` - -#### ScopeAnyPermission (OR Logic - Multiple Permissions) - -Use when user needs **at least one** of multiple complete permissions (can be different scopes): - -```python -from oxutils.permissions.perms import ScopeAnyPermission - -# User needs ANY of these permissions +# OR across multiple permissions @api_controller('/dashboard', permissions=[ - ScopeAnyPermission( - 'articles:r', # Can read articles - 'invoices:w:accountant',# OR can write invoices as accountant - 'reports:r:admin' # OR can read reports as admin - ) + ScopeAnyPermission('orders:create|approve', 'articles:read') ]) class DashboardController: - # Access granted if user has at least one permission pass -# Complex example with different scopes and contexts -@api_controller('/analytics', permissions=[ - ScopeAnyPermission( - 'analytics:r', - 'reports:r?department=finance', - 'data:w:admin' - ) -]) -class AnalyticsController: - # User needs ANY of these permissions to access - pass - -# Combining with method-level permissions -@api_controller('/content') -class ContentController: - @http_get('/', permissions=[ - ScopeAnyPermission('articles:r', 'pages:r', 'posts:r') - ]) - def list_content(self): - # Can read articles OR pages OR posts - pass - - @http_post('/', permissions=[ - ScopeAnyPermission('articles:w:editor', 'posts:w:editor') - ]) - def create_content(self): - # Can write articles as editor OR posts as editor - pass -``` - -**Comparison:** - -| Permission Class | Logic | Use Case | -|-----------------|-------|----------| -| `ScopePermission` | AND | User must have ALL actions (e.g., `'articles:rw'` = read AND write) | -| `ScopeAnyActionPermission` | OR | User needs ANY action on one scope (e.g., `'articles:rwd'` = read OR write OR delete) | -| `ScopeAnyPermission` | OR | User needs ANY complete permission (e.g., multiple scopes/roles) | - -### Global Extra Permissions - -Use ``extra_permissions()`` to inject permission instances globally across -all controllers. Define singleton permission instances in your own modules -and list their dotted paths in ``EXTRA_PERMISSIONS`` (settings.py). - -```python -# myapp/permissions.py -from ninja_extra.permissions import BasePermission - -class IsPremium(BasePermission): - def has_permission(self, request, controller): - return getattr(request.user, "is_premium", False) - -# Singleton — import_string will return this instance directly -IsPremium = IsPremium() - - -# settings.py -EXTRA_PERMISSIONS = [ - "myapp.permissions.IsPremium", -] - - -# controller -from oxutils.permissions.perms import extra_permissions - -@api_controller( - "/api", - permissions=[*extra_permissions(), ScopePermission("articles:r")], -) -class MyController: - ... -``` - -The result is cached via :func:`functools.lru_cache` — ``import_string`` -is only called once per process. - -### Assign Role to User - -```python -from oxutils.permissions.utils import assign_role - -# Assign role directly for a specific scope -assign_role(user, 'editor', 'articles', by=admin_user) - -# This creates Grants based on RoleGrants for 'editor' on 'articles' scope -``` - -### Assign Group to User - -```python -from oxutils.permissions.utils import assign_group - -# Assign all roles from a group -user_group = assign_group(user, 'staff', by=admin_user) - -# This: -# 1. Creates a UserGroup linking user to group -# 2. Assigns all roles from the group -``` - -### Revoke Permissions - -```python -from oxutils.permissions.utils import revoke_role, revoke_group - -# Revoke a single role for a specific scope -deleted_count, info = revoke_role(user, 'editor', 'articles') - -# Revoke entire group (removes all associated grants) -deleted_count, info = revoke_group(user, 'staff') -``` - -### Activate / Deactivate Permissions - -Toggle grants on/off without deleting them. Inactive grants are ignored -by `check()` and `any_action_check()`. - -```python -from oxutils.permissions.utils import ( - activate_user_permissions, - deactivate_user_permissions, -) - -# Deactivate ALL grants for a user -deactivate_user_permissions(user) -assert not check(user, 'articles', ['r']) - -# Reactivate -activate_user_permissions(user) -assert check(user, 'articles', ['r']) - -# Deactivate only grants for a specific scope -deactivate_user_permissions(user, scope='articles') - -# Deactivate only grants whose role belongs to a given app -deactivate_user_permissions(user, app='blog') - -# Both filters can be combined -activate_user_permissions(user, scope='articles', app='cms') -``` - -Both functions are **passive**: they never raise an exception, even when -no grant matches the given filters. - -### Override User Permissions - -```python -from oxutils.permissions.utils import override_grant - -# User has ['r', 'w', 'd'] on articles via role -# Override to read-only -override_grant(user, 'articles', actions=['r']) - -# Grant becomes locked (locked=True) -# Will NOT be affected by future group syncs - -# To remove a grant entirely -override_grant(user, 'articles', actions=[]) -``` - -### Synchronize Group +# Access manager (for built-in /access endpoints) +from oxutils.permissions.perms import access_manager -After modifying RoleGrants or group roles, sync all users: - -```python -from oxutils.permissions.utils import group_sync - -# Sync all users in the group -stats = group_sync('staff') -# Returns: {"users_synced": 5, "grants_updated": 15} - -# Sync specific roles only (performance optimization) -stats = group_sync('staff', role_slugs=['editor', 'viewer']) -# Returns: {"users_synced": 5, "grants_updated": 8} - -# Sync specific scope only (performance optimization) -stats = group_sync('staff', scope='articles') -# Returns: {"users_synced": 5, "grants_updated": 5} - -# Sync specific roles and scope (targeted sync) -stats = group_sync('staff', role_slugs=['editor'], scope='articles') -# Returns: {"users_synced": 5, "grants_updated": 3} - -# This: -# 1. Deletes old grants (except locked ones) -# 2. Recreates grants from current RoleGrants -# 3. Preserves locked grants (locked=True) -# 4. Filters by role_slugs and/or scope if provided -``` - -### Synchronize Role - -After modifying RoleGrants for a role, sync all independent role assignments: - -```python -from oxutils.permissions.utils import role_sync - -# Sync all users with independent role assignments -stats = role_sync('editor') -# Returns: {"grants_updated": 12} - -# Sync specific scope only (performance optimization) -stats = role_sync('editor', scope='articles') -# Returns: {"grants_updated": 3} - -# This: -# 1. Updates grants for users with independent role assignments (user_group=None) -# 2. Does NOT affect group-based grants (use group_sync for those) -# 3. Preserves locked grants (locked=True) -# 4. Updates actions and context directly (no delete/recreate) -``` - -## Advanced Usage - -### Role Permissions - -```python -# RoleGrant for all editors -RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r', 'w', 'd'] -) - -# All users with editor role get ['r', 'w', 'd'] on articles -assign_role(user1, 'editor', 'articles') -assign_group(user2, 'staff') # If staff group includes editor role -``` - -### Context-Based Permissions - -```python -# Create grant with context -Grant.objects.create( - user=user, - scope='articles', - actions=['r', 'w'], - context={'tenant_id': 123, 'status': 'published'} -) - -# Check with matching context -check(user, 'articles', ['w'], tenant_id=123, status='published') # True -check(user, 'articles', ['w'], tenant_id=456) # False -``` - -### Custom Grant Creation - -```python -from oxutils.permissions.services import PermissionService - -service = PermissionService() - -# Create a custom grant (not tied to any role) -grant = service.create_grant({ - 'user_id': user.id, - 'scope': 'reports', - 'actions': ['r', 'x'], - 'context': {'department': 'finance'} -}) -``` - -## Service Layer - -Use the service for business logic: - -```python -from oxutils.permissions.services import PermissionService - -service = PermissionService() - -# Assign role with traceability -role = service.assign_role_to_user( - user_id=user.id, - role_slug='editor', - by_user=admin_user -) - -# Assign group -roles = service.assign_group_to_user( - user_id=user.id, - group_slug='staff', - by_user=admin_user -) - -# Sync group -stats = service.sync_group('staff') -``` - -## Workflow Examples - -### Initial Setup - -```python -# 1. Create roles -admin = Role.objects.create(slug='admin', name='Administrator') -editor = Role.objects.create(slug='editor', name='Editor') - -# 2. Create RoleGrants -RoleGrant.objects.create( - role=admin, - scope='users', - actions=['r', 'w', 'd'] -) - -RoleGrant.objects.create( - role=editor, - scope='articles', - actions=['r', 'w'] -) - -# 3. Create group -staff = Group.objects.create(slug='staff', name='Staff') -staff.roles.add(editor) - -# 4. Assign to users -assign_group(user, 'staff', by=admin_user) -``` - -### Modify Permissions Globally - -```python -# Update RoleGrant -rg = RoleGrant.objects.get(role__slug='editor', scope='articles') -rg.actions = ['r', 'w', 'd'] # Add delete permission -rg.save() - -# Sync all users in groups that have this role -group_sync('staff') - -# All staff members now have delete permission -# EXCEPT those with locked grants -``` - -### Handle Permission Abuse - -```python -# Option 1: Override with restricted actions (permanent until manually reverted) -override_grant(user, 'articles', actions=['r', 'w']) - -# Option 2: Temporarily deactivate (preserves original actions, reversible) -deactivate_user_permissions(user, scope='articles') -# … investigation period … -activate_user_permissions(user, scope='articles') -``` - -### Temporary Elevated Access - -```python -# Give temporary admin access for a specific scope -assign_role(user, 'admin', 'articles', by=manager) - -# Later, revoke it -revoke_role(user, 'admin', 'articles') - -# User returns to their group permissions -``` - -## Performance - -### Bulk Operations - -The system uses bulk operations for optimal performance: - -```python -# group_sync uses bulk_create with update_conflicts -# 100 users × 10 grants = efficient bulk operations -stats = group_sync('large-group') - -# Use filters for better performance on large datasets -stats = group_sync('large-group', role_slugs=['editor'], scope='articles') - -# role_sync uses direct updates (no delete/recreate) -stats = role_sync('editor', scope='articles') -``` - -### Permission Check Caching - -Enable caching to improve permission check performance: - -```python -# settings.py -CACHE_CHECK_PERMISSION = True - -# Requires cacheops in INSTALLED_APPS -INSTALLED_APPS = [ - # ... - 'cacheops', - 'oxutils.permissions', -] - -# Configure cacheops -CACHEOPS_REDIS = "redis://localhost:6379/1" -CACHEOPS = { - 'permissions.*': {'ops': 'all', 'timeout': 60*60}, -} -``` - -**How it works:** - -- When `CACHE_CHECK_PERMISSION = True`, permission checks are cached for 15 minutes -- Cache is automatically invalidated when `Grant` model changes -- Uses `cacheops` `@cached_as` decorator -- Falls back to non-cached checks if `CACHE_CHECK_PERMISSION = False` - -**Cached functions:** - -```python -from oxutils.permissions.caches import ( - cache_check, # Caches check() - cache_any_action_check, # Caches any_action_check() - cache_any_permission_check # Caches any_permission_check() -) - -# All permission classes automatically use cached versions -ScopePermission('articles:r') # Uses cache_check -ScopeAnyActionPermission('articles:rwd') # Uses cache_any_action_check -ScopeAnyPermission('articles:r', 'invoices:w') # Uses cache_any_permission_check -``` - -**Performance impact:** - -```python -# Without cache: Database query every time -check(user, 'articles', ['r']) # ~5-10ms -any_action_check(user, 'articles', ['r', 'w', 'd']) # ~5-10ms -any_permission_check(user, 'articles:r', 'invoices:w') # ~5-10ms - -# With cache: Redis lookup after first check -check(user, 'articles', ['r']) # ~0.5-1ms (10x faster) -any_action_check(user, 'articles', ['r', 'w', 'd']) # ~0.5-1ms (10x faster) -any_permission_check(user, 'articles:r', 'invoices:w') # ~0.5-1ms (10x faster) -``` - -**Note:** All permission check functions (`check`, `str_check`, `any_action_check`, `any_permission_check`) benefit from caching when enabled. - -### Query Optimization - -```python -# Grants use select_related for efficient queries -grant = Grant.objects.select_related('user_group', 'role').get(id=1) - -# Indexes on frequently queried fields -# - (user, scope) -# - (user_group) -# - GIN indexes on actions and context (PostgreSQL) -``` - -## Exception Handling - -Custom exceptions for clear error messages: - -```python -from oxutils.permissions.exceptions import ( - RoleNotFoundException, - GroupNotFoundException, - GrantNotFoundException, - RoleAlreadyAssignedException, - GroupAlreadyAssignedException, -) - -try: - assign_role(user, 'invalid-role', 'articles') -except RoleNotFoundException as e: - # Handle: "Le rôle 'invalid-role' n'existe pas" +@api_controller('/admin', permissions=[IsAuthenticated & access_manager('read/write')]) +class AdminController: pass ``` -All exceptions are automatically converted to appropriate HTTP responses by the service layer. - -## Database Constraints - -### Unique Constraints - -- **Role**: `unique(slug)` -- **Group**: `unique(slug)` -- **UserGroup**: `unique(user, group)` -- **RoleGrant**: `unique(role, scope)` -- **Grant**: `unique(user, scope, role, user_group)` - -### Indexes - -- Grant: `(user, scope)`, `(user_group)`, GIN on `actions`, GIN on `context` -- UserGroup: `(user, group)` -- RoleGrant: `(role)`, `(role, scope)` - -## Best Practices - -### 1. Use Groups for Organization +### Frontend — Action Labels ```python -# ✅ Good -assign_group(user, 'staff') +from oxutils.permissions.actions import get_action_label, get_scope_actions_labels -# ❌ Avoid (unless specific need) -assign_role(user, 'role1', 'articles') -assign_role(user, 'role2', 'articles') -assign_role(user, 'role3', 'articles') -``` +# Single label +get_action_label('orders', 'create') # → "Créer" (in French locale) -### 2. Define Clear RoleGrants - -```python -# ✅ Good: Clear RoleGrant -RoleGrant.objects.create( - role=editor, - scope='articles', - actions=['r', 'w'] -) +# All labels for a scope +get_scope_actions_labels('orders') +# → {"create": "Créer", "approve": "Approuver", "cancel": "Annuler", ...} ``` -### 3. Always Sync After Changes +### Assign / Revoke / Override ```python -# Modify RoleGrant -role_grant.actions = ['r', 'w', 'd'] -role_grant.save() - -# ✅ Sync immediately -# For group-based grants: -group_sync('staff') +from oxutils.permissions.utils import assign_role, revoke_role, override_grant -# For independent role assignments: -role_sync('editor') - -# Or sync both with filters for performance: -group_sync('staff', role_slugs=['editor'], scope='articles') -role_sync('editor', scope='articles') +assign_role(user, 'editor', 'articles', by=admin) +revoke_role(user, 'editor', 'articles') +override_grant(user, 'articles', ['publish']) # sets locked=True +override_grant(user, 'articles', []) # deletes grant ``` -### 4. Use Context for Multi-Tenancy +### Activate / Deactivate ```python -# Grant with tenant context -Grant.objects.create( - user=user, - scope='data', - actions=['r', 'w'], - context={'tenant_id': 123} -) - -# Check with tenant -check(user, 'data', ['w'], tenant_id=123) # True -check(user, 'data', ['w'], tenant_id=456) # False -``` +from oxutils.permissions.utils import activate_user_permissions, deactivate_user_permissions -### 5. Track Changes - -```python -# Always pass by parameter for audit trail -assign_role(user, 'editor', 'articles', by=admin_user) -assign_group(user, 'staff', by=admin_user) +deactivate_user_permissions(user) # all scopes +deactivate_user_permissions(user, scope='articles') # single scope +activate_user_permissions(user) ``` -### 6. Enable Caching for Production +### Sync After Changes ```python -# settings.py -CACHE_CHECK_PERMISSION = True # Enable in production - -# Ensure cacheops is configured -INSTALLED_APPS = ['cacheops', ...] -CACHEOPS_REDIS = "redis://localhost:6379/1" -``` - -## Troubleshooting - -### Permissions Not Applied +from oxutils.permissions.utils import group_sync, role_sync -```python -# After modifying RoleGrants, sync the group +# After modifying a RoleGrant, sync affected users group_sync('staff') - -# Or sync specific role/scope for better performance group_sync('staff', role_slugs=['editor'], scope='articles') - -# Also sync independent role assignments role_sync('editor', scope='articles') ``` -### Override Not Working - -```python -# Check if grant has role=None -grant = Grant.objects.get(user=user, scope='articles') -print(grant.role) # Should be None for custom grant -``` - -### Check Returns False Despite Existing Grant - -```python -# The grant may be inactive -grant = Grant.objects.get(user=user, scope='articles') -if not grant.is_active: - # Reactivate it - activate_user_permissions(user, scope='articles') -``` - -### Cache Not Working - -```python -# Verify cacheops is installed -python -c "import cacheops" - -# Check settings -from django.conf import settings -print(settings.CACHE_CHECK_PERMISSION) # Should be True -print('cacheops' in settings.INSTALLED_APPS) # Should be True - -# Clear cache manually if needed -from cacheops import invalidate_model -from oxutils.permissions.models import Grant -invalidate_model(Grant) -``` - -### Bulk Create Conflicts - -```python -# Ensure unique constraint fields match -# Grant: unique(user, scope, role, user_group) -``` - -## Migration Notes +## Permission Classes Comparison -After model changes: - -```bash -python manage.py makemigrations permissions -python manage.py migrate permissions -``` - -Key migrations: -- Initial: Creates all models with constraints and indexes -- RoleGrant unique constraint: `(role, scope)` -- Add `created_by` to Grant: Enables audit trail -- Update Grant constraint: Includes `user_group` in uniqueness - -## Testing +| Class | Logic | Example | +|---|---|---| +| `ScopePermission` | Respects `/` (AND) or `\|` (OR) | `'orders:create/approve'` | +| `ScopeAnyActionPermission` | Always OR | `'orders:create/approve'` → OR | +| `ScopeAnyPermission` | OR across multiple strings | `'orders:create\|approve', 'articles:read'` | -```python -from django.test import TestCase -from oxutils.permissions.utils import ( - activate_user_permissions, - deactivate_user_permissions, - assign_role, - check, - override_grant, -) +## Migration from 0.4.x -class PermissionsTest(TestCase): - def setUp(self): - self.role = Role.objects.create(slug='editor', name='Editor') - RoleGrant.objects.create( - role=self.role, - scope='articles', - actions=['r', 'w'] - ) - - def test_role_assignment(self): - assign_role(self.user, 'editor', 'articles') - - self.assertTrue(check(self.user, 'articles', ['r'])) - self.assertTrue(check(self.user, 'articles', ['w'])) - self.assertFalse(check(self.user, 'articles', ['d'])) - - def test_override(self): - assign_role(self.user, 'editor', 'articles') - override_grant(self.user, 'articles', actions=['r']) - - self.assertTrue(check(self.user, 'articles', ['r'])) - self.assertFalse(check(self.user, 'articles', ['w'])) - - def test_deactivate_reactivate(self): - assign_role(self.user, 'editor', 'articles') - - deactivate_user_permissions(self.user) - self.assertFalse(check(self.user, 'articles', ['r'])) - - activate_user_permissions(self.user) - self.assertTrue(check(self.user, 'articles', ['r'])) -``` +1. **Actions**: Replace single-letter actions (`r`, `w`, `d`, `u`, `a`) with named actions + in `PERMISSION_PRESET["actions"]`. +2. **RoleGrants**: `actions: ["r", "w"]` → `actions: ["read", "write"]`. +3. **Permission strings**: `'articles:rw'` → `'articles:read/write'` (AND) or `'articles:read|write'` (OR). +4. **Controllers**: `access_manager('rw')` → `access_manager('read/write')`. +5. **Run migration** `0010_increase_action_max_length` (included). +6. **Define actions** per scope in `PERMISSION_PRESET["actions"]` with `implies` and optional `label`. -## Related Documentation +## Best Practices -- [Audit System](audit.md) - Track permission changes -- [Mixins](mixins.md) - BaseService pattern -- [Settings](settings.md) - Configuration options +1. **Define actions per scope** — each scope in its owning app. +2. **Always add `label`** to every action and scope — the frontend needs them for i18n. + Use `gettext_lazy` (`_()`) so they're resolved in the user's locale. +3. **Use `implies`** for natural hierarchies (approve → create, publish → write → read). +4. **Sync after changes** — always call `group_sync()` / `role_sync()` after modifying `RoleGrant`. +5. **Use context for multi-tenancy** — filter grants with `tenant_id`, `department`, etc. +6. **One scope = one app** — strict ownership prevents conflicts. +7. **Use dict format for `ACCESS_SCOPES`** when the scope is displayed in the frontend — + `{"key": "orders", "label": _("Orders")}` gives you i18n for free. diff --git a/pyproject.toml b/pyproject.toml index 483c29a..190ed01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oxutils" -version = "0.4.6" +version = "0.5.0" description = "Production-ready utilities for Django applications in the Oxiliere ecosystem" readme = "README.md" license = "LGPL-3.0-only" diff --git a/src/oxutils/__init__.py b/src/oxutils/__init__.py index 9f3c46c..f729ef7 100644 --- a/src/oxutils/__init__.py +++ b/src/oxutils/__init__.py @@ -40,7 +40,7 @@ image validation, bound-request detection """ -__version__ = "0.4.6" +__version__ = "0.5.0" from oxutils.conf import AUDIT_MIDDLEWARE, UTILS_APPS from oxutils.settings import oxi_settings diff --git a/src/oxutils/permissions/actions.py b/src/oxutils/permissions/actions.py index 8cc9d3c..0af4b83 100644 --- a/src/oxutils/permissions/actions.py +++ b/src/oxutils/permissions/actions.py @@ -1,57 +1,178 @@ -# actions.py +""" +Domain-oriented actions defined via PERMISSION_PRESET["actions"]. -READ = "r" -WRITE = "w" -DELETE = "d" -UPDATE = "u" -APPROVE = "a" +Each scope has its own set of named actions with optional hierarchy +(via ``implies``). Actions are no longer hard-coded single letters; +the developer defines them per scope (or globally) in the preset. -ACTIONS = [READ, WRITE, DELETE, UPDATE, APPROVE] +Example preset:: + PERMISSION_PRESET = { + "actions": { + "orders": { + "create": {"implies": []}, + "approve": {"implies": ["create"]}, + "cancel": {"implies": []}, + "refund": {"implies": ["approve"]}, + }, + "articles": { + "read": {"implies": []}, + "write": {"implies": ["read"]}, + "publish": {"implies": ["write"]}, + "archive": {"implies": ["publish"]}, + }, + }, + "roles": [...], + "groups": [...], + "role_grants": [...], + } -ACTION_HIERARCHY = { - "r": set(), # read - "w": {"r"}, # write ⇒ read - "u": {"r"}, # update ⇒ read - "d": {"r", "w"}, # delete ⇒ write ⇒ read - "a": {"r"}, # approve ⇒ read -} +If a scope is not declared in ``actions``, an empty dict is returned and +no hierarchy expansion occurs (actions are treated as independent). +""" +from __future__ import annotations -def collapse_actions(actions: list[str]) -> set[str]: - """ - ['d','w','r'] -> {'d'} - ['w','r'] -> {'w'} - ['r'] -> {'r'} - """ - actions = set(actions) - roots = set(actions) +def _get_all_actions() -> dict[str, dict[str, dict]]: + """Return all action definitions from ``PERMISSION_PRESET["actions"]``.""" + from django.conf import settings - # Remove all implied actions from roots - for action in list(roots): - if action in ACTION_HIERARCHY: - implied = ACTION_HIERARCHY[action] - roots -= implied + preset = getattr(settings, "PERMISSION_PRESET", {}) + actions = preset.get("actions", {}) + if not isinstance(actions, dict): + return {} + return actions - return roots +def get_actions_for_scope(scope: str) -> dict[str, dict]: + """Return the action definitions for a given *scope*. -def expand_actions(actions: list[str]) -> list[str]: + Returns: + Dict mapping action name → definition (currently only ``implies`` key). """ - ['w'] -> ['w', 'r'] - ['d'] -> ['d', 'w', 'r'] - ['a', 'w'] -> ['a', 'w', 'r'] + return _get_all_actions().get(scope, {}) + + +def get_valid_actions(scope: str) -> list[str]: + """Return the list of valid action names for *scope*.""" + return list(get_actions_for_scope(scope).keys()) + + +def get_all_valid_actions() -> set[str]: + """Return the union of all action names across all scopes.""" + all_actions: set[str] = set() + for scope_actions in _get_all_actions().values(): + all_actions.update(scope_actions.keys()) + return all_actions + + +def get_implied_actions(scope: str, action: str) -> set[str]: + """Return actions that *action* implies on *scope*.""" + actions_for_scope = get_actions_for_scope(scope) + action_def = actions_for_scope.get(action, {}) + implies = action_def.get("implies", []) + return set(implies) if isinstance(implies, list) else set() + + +def expand_actions(scope: str, actions: list[str]) -> list[str]: + """Recursively expand *actions* to include everything they imply. + + Example:: + + >>> # If "approve" implies "create": + >>> expand_actions("orders", ["approve"]) + ["approve", "create"] """ - expanded = set(actions) + expanded: set[str] = set(actions) + stack: list[str] = list(actions) - stack = list(actions) while stack: action = stack.pop() - implied = ACTION_HIERARCHY.get(action, set()) - - for a in implied: - if a not in expanded: - expanded.add(a) - stack.append(a) + for implied in get_implied_actions(scope, action): + if implied not in expanded: + expanded.add(implied) + stack.append(implied) return sorted(expanded) + + +def collapse_actions(scope: str, actions: list[str]) -> set[str]: + """Remove implied actions, keeping only the most-specific (root) actions. + + Example:: + + >>> # If "approve" implies "create": + >>> collapse_actions("orders", ["approve", "create"]) + {"approve"} + """ + root = set(actions) + for action in list(root): + implied = get_implied_actions(scope, action) + root -= implied + return root + + +def validate_actions_for_scope(scope: str, actions: list[str]) -> list[str]: + """Validate that *actions* are declared for *scope*. + + Returns *actions* unchanged if valid. + + Raises: + ValueError: if one or more actions are not valid for the scope. + """ + valid = get_valid_actions(scope) + + # If the scope has no explicit actions defined, allow anything + # (the developer may rely on implicit validation or global presets). + if not valid: + return actions + + invalid = [a for a in actions if a not in valid] + if invalid: + raise ValueError( + f"Invalid actions for scope '{scope}': {invalid}. " + f"Valid actions: {valid}" + ) + return actions + + +def get_action_label(scope: str, action: str) -> str: + """Return the human-readable (translated) label for *action* on *scope*. + + Falls back to the action key itself if no ``label`` is defined. + + Example preset:: + + PERMISSION_PRESET = { + "actions": { + "orders": { + "create": { + "implies": [], + "label": _("Create"), + }, + }, + }, + } + + Usage:: + + >>> get_action_label("orders", "create") + "Créer" # or "Create" depending on active language + """ + action_def = get_actions_for_scope(scope).get(action, {}) + label = action_def.get("label") + if label is not None: + return str(label) # force evaluation of lazy translations + return str(action) + + +def get_scope_actions_labels(scope: str) -> dict[str, str]: + """Return all action → label mappings for a scope. + + Returns: + Dict mapping action key → translated label. + """ + return { + action: get_action_label(scope, action) + for action in get_valid_actions(scope) + } diff --git a/src/oxutils/permissions/checks.py b/src/oxutils/permissions/checks.py index 4a50537..6d4b608 100644 --- a/src/oxutils/permissions/checks.py +++ b/src/oxutils/permissions/checks.py @@ -14,13 +14,13 @@ ] CACHE_CHECK_PERMISSION = False - + ACCESS_SCOPES = [ "users", "articles", "comments" ] - + PERMISSION_PRESET = { "roles": [...], "group": [...], @@ -29,14 +29,14 @@ """ from django.conf import settings -from django.core.checks import Error, Warning, register, Tags +from django.core.checks import Error, Tags, Warning, register @register(Tags.security) def check_permission_settings(app_configs, **kwargs): """ Validate permission-related settings. - + Checks: - ACCESS_MANAGER_SCOPE is defined - ACCESS_MANAGER_GROUP is defined (can be None) @@ -51,47 +51,47 @@ def check_permission_settings(app_configs, **kwargs): - PERMISSION_PRESET roles/groups with 'app' have their app in ACCESS_APPLICATIONS """ errors = [] - - # Check ACCESS_MANAGER_SCOPE + + # Check ACCESS_MANAGER_SCOPE — optional, defaults to "access" if not hasattr(settings, 'ACCESS_MANAGER_SCOPE'): errors.append( - Error( - 'ACCESS_MANAGER_SCOPE is not defined', - hint='Add ACCESS_MANAGER_SCOPE = "access" to your settings', - id='permissions.E001', + Warning( + 'ACCESS_MANAGER_SCOPE is not defined (defaulting to "access")', + hint='Add ACCESS_MANAGER_SCOPE = "access" to your settings to be explicit.', + id='permissions.W003', ) ) - + # Check ACCESS_MANAGER_GROUP - if not hasattr(settings, 'ACCESS_MANAGER_GROUP'): + if not hasattr(settings, "ACCESS_MANAGER_GROUP"): errors.append( Error( - 'ACCESS_MANAGER_GROUP is not defined', + "ACCESS_MANAGER_GROUP is not defined", hint='Add ACCESS_MANAGER_GROUP = "manager" or None to your settings', - id='permissions.E002', + id="permissions.E002", ) ) - + # Check ACCESS_MANAGER_ROLE - if not hasattr(settings, 'ACCESS_MANAGER_ROLE'): + if not hasattr(settings, "ACCESS_MANAGER_ROLE"): errors.append( Error( - 'ACCESS_MANAGER_ROLE is not defined', + "ACCESS_MANAGER_ROLE is not defined", hint='Add ACCESS_MANAGER_ROLE = "admin" or None to your settings', - id='permissions.E013', + id="permissions.E013", ) ) - + # Check ACCESS_MANAGER_CONTEXT - if not hasattr(settings, 'ACCESS_MANAGER_CONTEXT'): + if not hasattr(settings, "ACCESS_MANAGER_CONTEXT"): errors.append( Error( - 'ACCESS_MANAGER_CONTEXT is not defined', - hint='Add ACCESS_MANAGER_CONTEXT = {} to your settings', - id='permissions.E003', + "ACCESS_MANAGER_CONTEXT is not defined", + hint="Add ACCESS_MANAGER_CONTEXT = {} to your settings", + id="permissions.E003", ) ) - + # Check ACCESS_SCOPES if not hasattr(settings, 'ACCESS_SCOPES'): errors.append( @@ -102,23 +102,24 @@ def check_permission_settings(app_configs, **kwargs): ) ) else: - # Validate ACCESS_SCOPES is a list + # Validate ACCESS_SCOPES is a list (of strings or dicts) if not isinstance(settings.ACCESS_SCOPES, list): errors.append( Error( 'ACCESS_SCOPES must be a list', - hint='Set ACCESS_SCOPES = ["users", "articles", ...]', + hint='Set ACCESS_SCOPES = ["users", "articles", ...] or ' + '[{"key": "...", "label": _("...")}, ...]', id='permissions.E005', ) ) - + # Check PERMISSION_PRESET - if not hasattr(settings, 'PERMISSION_PRESET'): + if not hasattr(settings, "PERMISSION_PRESET"): errors.append( Warning( - 'PERMISSION_PRESET is not defined', - hint='Add PERMISSION_PRESET dict to your settings or use load_permission_preset', - id='permissions.W001', + "PERMISSION_PRESET is not defined", + hint="Add PERMISSION_PRESET dict to your settings or use load_permission_preset", + id="permissions.W001", ) ) else: @@ -127,179 +128,246 @@ def check_permission_settings(app_configs, **kwargs): if not isinstance(preset, dict): errors.append( Error( - 'PERMISSION_PRESET must be a dictionary', - id='permissions.E006', + "PERMISSION_PRESET must be a dictionary", + id="permissions.E006", ) ) else: # Check required keys - required_keys = ['roles', 'group', 'role_grants'] + required_keys = ["roles", "group", "role_grants"] for key in required_keys: if key not in preset: errors.append( Error( - f'PERMISSION_PRESET is missing required key: {key}', + f"PERMISSION_PRESET is missing required key: {key}", hint=f'Add "{key}" key to PERMISSION_PRESET', - id=f'permissions.E007', + id=f"permissions.E007", ) ) - + + # Validate that actions used in role_grants exist in the preset + if "actions" in preset and isinstance(preset["actions"], dict): + all_defined_actions: set[str] = set() + for scope_actions in preset["actions"].values(): + if isinstance(scope_actions, dict): + all_defined_actions.update(scope_actions.keys()) + + for rg in preset.get("role_grants", []): + rg_actions = rg.get("actions", []) + for action in rg_actions: + if action not in all_defined_actions: + errors.append( + Warning( + f'Action "{action}" in role_grant (role={rg.get("role")}, ' + f"scope={rg.get('scope')}) is not defined in " + f'PERMISSION_PRESET["actions"]', + hint=f'Add "{action}" to PERMISSION_PRESET["actions"]["scope"]', + id="permissions.W002", + ) + ) + # Check ACCESS_APPLICATIONS (optional, but must be a list if defined) - has_applications = hasattr(settings, 'ACCESS_APPLICATIONS') + has_applications = hasattr(settings, "ACCESS_APPLICATIONS") if has_applications: if not isinstance(settings.ACCESS_APPLICATIONS, list): errors.append( Error( - 'ACCESS_APPLICATIONS must be a list', + "ACCESS_APPLICATIONS must be a list", hint='Set ACCESS_APPLICATIONS = ["crm", "accounting", ...]', - id='permissions.E015', + id="permissions.E015", ) ) has_applications = False - - # Cross-validation: ACCESS_MANAGER_SCOPE in ACCESS_SCOPES - if (hasattr(settings, 'ACCESS_MANAGER_SCOPE') and - hasattr(settings, 'ACCESS_SCOPES') and - isinstance(settings.ACCESS_SCOPES, list)): - - if settings.ACCESS_MANAGER_SCOPE not in settings.ACCESS_SCOPES: + + # Cross-validation: ACCESS_MANAGER_SCOPE in ACCESS_SCOPES — warning only + # (the module owns the "access" scope via its permissions.py) + if ( + hasattr(settings, "ACCESS_MANAGER_SCOPE") + and hasattr(settings, "ACCESS_SCOPES") + and isinstance(settings.ACCESS_SCOPES, list) + ): + scope_keys = { + s["key"] if isinstance(s, dict) else str(s) + for s in settings.ACCESS_SCOPES + } + if settings.ACCESS_MANAGER_SCOPE not in scope_keys: errors.append( - Error( + Warning( f'ACCESS_MANAGER_SCOPE "{settings.ACCESS_MANAGER_SCOPE}" is not in ACCESS_SCOPES', - hint=f'Add "{settings.ACCESS_MANAGER_SCOPE}" to ACCESS_SCOPES list', - id='permissions.E008', + hint=f'Add "{settings.ACCESS_MANAGER_SCOPE}" to ACCESS_SCOPES list if you ' + f'want it visible in the frontend. The module manages it internally.', + id="permissions.W004", ) ) - + # Cross-validation: ACCESS_MANAGER_GROUP in PERMISSION_PRESET groups - if (hasattr(settings, 'ACCESS_MANAGER_GROUP') and - settings.ACCESS_MANAGER_GROUP is not None and - hasattr(settings, 'PERMISSION_PRESET') and - isinstance(settings.PERMISSION_PRESET, dict) and - 'group' in settings.PERMISSION_PRESET): - - group_slugs = [g.get('slug') for g in settings.PERMISSION_PRESET.get('group', [])] - + if ( + hasattr(settings, "ACCESS_MANAGER_GROUP") + and settings.ACCESS_MANAGER_GROUP is not None + and hasattr(settings, "PERMISSION_PRESET") + and isinstance(settings.PERMISSION_PRESET, dict) + and "group" in settings.PERMISSION_PRESET + ): + group_slugs = [g.get("slug") for g in settings.PERMISSION_PRESET.get("group", [])] + if settings.ACCESS_MANAGER_GROUP not in group_slugs: errors.append( Error( f'ACCESS_MANAGER_GROUP "{settings.ACCESS_MANAGER_GROUP}" is not in PERMISSION_PRESET groups', hint=f'Add a group with slug "{settings.ACCESS_MANAGER_GROUP}" to PERMISSION_PRESET["group"]', - id='permissions.E009', + id="permissions.E009", ) ) - + # Cross-validation: ACCESS_MANAGER_ROLE in PERMISSION_PRESET roles - if (hasattr(settings, 'ACCESS_MANAGER_ROLE') and - settings.ACCESS_MANAGER_ROLE is not None and - hasattr(settings, 'PERMISSION_PRESET') and - isinstance(settings.PERMISSION_PRESET, dict) and - 'roles' in settings.PERMISSION_PRESET): - - role_slugs = [r.get('slug') for r in settings.PERMISSION_PRESET.get('roles', [])] - + if ( + hasattr(settings, "ACCESS_MANAGER_ROLE") + and settings.ACCESS_MANAGER_ROLE is not None + and hasattr(settings, "PERMISSION_PRESET") + and isinstance(settings.PERMISSION_PRESET, dict) + and "roles" in settings.PERMISSION_PRESET + ): + role_slugs = [r.get("slug") for r in settings.PERMISSION_PRESET.get("roles", [])] + if settings.ACCESS_MANAGER_ROLE not in role_slugs: errors.append( Error( f'ACCESS_MANAGER_ROLE "{settings.ACCESS_MANAGER_ROLE}" is not in PERMISSION_PRESET roles', hint=f'Add a role with slug "{settings.ACCESS_MANAGER_ROLE}" to PERMISSION_PRESET["roles"]', - id='permissions.E014', + id="permissions.E014", ) ) - + # Cross-validation: PERMISSION_PRESET roles/groups app values in ACCESS_APPLICATIONS - if (has_applications and - hasattr(settings, 'PERMISSION_PRESET') and - isinstance(settings.PERMISSION_PRESET, dict)): - + if ( + has_applications + and hasattr(settings, "PERMISSION_PRESET") + and isinstance(settings.PERMISSION_PRESET, dict) + ): apps_list = settings.ACCESS_APPLICATIONS - - for role_data in settings.PERMISSION_PRESET.get('roles', []): - app = role_data.get('app') + + for role_data in settings.PERMISSION_PRESET.get("roles", []): + app = role_data.get("app") if app and app not in apps_list: errors.append( Error( f'Role "{role_data.get("slug")}" has app "{app}" which is not in ACCESS_APPLICATIONS', hint=f'Add "{app}" to ACCESS_APPLICATIONS or remove "app" from this role', - id='permissions.E016', + id="permissions.E016", ) ) - - for group_data in settings.PERMISSION_PRESET.get('group', []): - app = group_data.get('app') + + for group_data in settings.PERMISSION_PRESET.get("group", []): + app = group_data.get("app") if app and app not in apps_list: errors.append( Error( f'Group "{group_data.get("slug")}" has app "{app}" which is not in ACCESS_APPLICATIONS', hint=f'Add "{app}" to ACCESS_APPLICATIONS or remove "app" from this group', - id='permissions.E017', + id="permissions.E017", ) ) - + + # Cross-validation: scope ownership — each scope in actions must be unique across apps + if hasattr(settings, "PERMISSION_PRESET") and isinstance(settings.PERMISSION_PRESET, dict): + scope_owners: dict[str, str] = {} + + # Collect scopes from the base preset (settings.py) + base_actions = settings.PERMISSION_PRESET.get("actions", {}) + if isinstance(base_actions, dict): + for scope in base_actions: + scope_owners[scope] = "settings.PERMISSION_PRESET" + + # Collect scopes from discovered app presets + from oxutils.permissions.presets import discover_app_presets + + for preset in discover_app_presets(): + app_label = preset.get("_app_label", "unknown") + preset_actions = preset.get("actions", {}) + if not isinstance(preset_actions, dict): + continue + for scope in preset_actions: + if scope in scope_owners: + errors.append( + Error( + f'Scope "{scope}" is already owned by "{scope_owners[scope]}". ' + f'App "{app_label}" cannot redefine it.', + hint=( + f"Each scope must be owned by exactly one app. " + f'Remove the scope "{scope}" from app "{app_label}" ' + f"or use a different scope name." + ), + id="permissions.E022", + ) + ) + else: + scope_owners[scope] = app_label + # Cross-validation: roles/groups with app require ACCESS_APPLICATIONS - if (not has_applications and - hasattr(settings, 'PERMISSION_PRESET') and - isinstance(settings.PERMISSION_PRESET, dict)): - + if ( + not has_applications + and hasattr(settings, "PERMISSION_PRESET") + and isinstance(settings.PERMISSION_PRESET, dict) + ): has_app_attr = False - for role_data in settings.PERMISSION_PRESET.get('roles', []): - if role_data.get('app'): + for role_data in settings.PERMISSION_PRESET.get("roles", []): + if role_data.get("app"): has_app_attr = True break - + if not has_app_attr: - for group_data in settings.PERMISSION_PRESET.get('group', []): - if group_data.get('app'): + for group_data in settings.PERMISSION_PRESET.get("group", []): + if group_data.get("app"): has_app_attr = True break - + if has_app_attr: errors.append( Error( 'ACCESS_APPLICATIONS is required when roles or groups define an "app" attribute', hint='Add ACCESS_APPLICATIONS = ["crm", "oxutils", ...] to your settings', - id='permissions.E018', + id="permissions.E018", ) ) - + # Validate ACCESS_MANAGER_CONTEXT is a dict - if hasattr(settings, 'ACCESS_MANAGER_CONTEXT'): + if hasattr(settings, "ACCESS_MANAGER_CONTEXT"): if not isinstance(settings.ACCESS_MANAGER_CONTEXT, dict): errors.append( Error( - 'ACCESS_MANAGER_CONTEXT must be a dictionary', - hint='Set ACCESS_MANAGER_CONTEXT = {}', - id='permissions.E010', + "ACCESS_MANAGER_CONTEXT must be a dictionary", + hint="Set ACCESS_MANAGER_CONTEXT = {}", + id="permissions.E010", ) ) - + # Check CACHE_CHECK_PERMISSION and cacheops dependency - if hasattr(settings, 'CACHE_CHECK_PERMISSION') and settings.CACHE_CHECK_PERMISSION: - if not hasattr(settings, 'INSTALLED_APPS'): + if hasattr(settings, "CACHE_CHECK_PERMISSION") and settings.CACHE_CHECK_PERMISSION: + if not hasattr(settings, "INSTALLED_APPS"): errors.append( Error( - 'INSTALLED_APPS is not defined', - id='permissions.E011', + "INSTALLED_APPS is not defined", + id="permissions.E011", ) ) - elif 'cacheops' not in settings.INSTALLED_APPS: + elif "cacheops" not in settings.INSTALLED_APPS: errors.append( Error( - 'CACHE_CHECK_PERMISSION is True but cacheops is not in INSTALLED_APPS', + "CACHE_CHECK_PERMISSION is True but cacheops is not in INSTALLED_APPS", hint='Add "cacheops" to INSTALLED_APPS or set CACHE_CHECK_PERMISSION = False', - id='permissions.E012', + id="permissions.E012", ) ) - + # Validate EXTRA_PERMISSIONS - if hasattr(settings, 'EXTRA_PERMISSIONS'): + if hasattr(settings, "EXTRA_PERMISSIONS"): extra = settings.EXTRA_PERMISSIONS if not isinstance(extra, (list, tuple)): errors.append( Error( - 'EXTRA_PERMISSIONS must be a list or tuple', + "EXTRA_PERMISSIONS must be a list or tuple", hint='Set EXTRA_PERMISSIONS = ["dotted.path.ToPermission", ...]', - id='permissions.E019', + id="permissions.E019", ) ) else: @@ -309,9 +377,9 @@ def check_permission_settings(app_configs, **kwargs): if not isinstance(path, str): errors.append( Error( - f'Each entry in EXTRA_PERMISSIONS must be a string, got {type(path).__name__}', + f"Each entry in EXTRA_PERMISSIONS must be a string, got {type(path).__name__}", hint='Use dotted paths like "myapp.permissions.MyPermission"', - id='permissions.E020', + id="permissions.E020", ) ) continue @@ -321,8 +389,8 @@ def check_permission_settings(app_configs, **kwargs): errors.append( Error( f'Cannot import "{path}" from EXTRA_PERMISSIONS', - hint='Check that the module and class exist', - id='permissions.E021', + hint="Check that the module and class exist", + id="permissions.E021", ) ) continue diff --git a/src/oxutils/permissions/controllers.py b/src/oxutils/permissions/controllers.py index 6013978..e91f00f 100644 --- a/src/oxutils/permissions/controllers.py +++ b/src/oxutils/permissions/controllers.py @@ -1,38 +1,58 @@ from typing import List, Optional from uuid import UUID + from django.conf import settings from django.http import HttpRequest from ninja_extra import ( - api_controller, ControllerBase, + api_controller, + http_delete, http_get, http_post, http_put, - http_delete, ) from ninja_extra.permissions import IsAuthenticated + from . import schemas -from .services import PermissionService +from .actions import get_scope_actions_labels from .perms import access_manager +from .services import PermissionService - - -@api_controller( - "/access", - permissions=[ - IsAuthenticated & access_manager('r') - ] -) +@api_controller("/access", permissions=[IsAuthenticated & access_manager("read")]) class PermissionController(ControllerBase): """ Contrôleur pour la gestion des permissions, rôles et groupes. """ + service = PermissionService() - @http_get('/scopes', response=List[str]) + @http_get('/scopes', response=List[schemas.ScopeSchema]) def list_scopes(self): - return getattr(settings, 'ACCESS_SCOPES', []) + """Liste tous les scopes avec leurs labels (affichage frontend).""" + raw = getattr(settings, 'ACCESS_SCOPES', []) + result: list[dict] = [] + for entry in raw: + if isinstance(entry, dict): + result.append({ + "key": entry["key"], + "label": str(entry["label"]) if entry.get("label") else str(entry["key"]), + }) + else: + result.append({"key": str(entry), "label": str(entry)}) + return result + + @http_get("/scopes/{scope}/actions", response=schemas.ScopeActionsResponseSchema) + def list_scope_actions(self, scope: str): + """ + Liste les actions disponibles pour un scope avec leurs labels traduits. + Utile pour le frontend (affichage des actions dans la langue configurée). + """ + labels = get_scope_actions_labels(scope) + return { + "scope": scope, + "actions": [{"key": key, "label": label} for key, label in labels.items()], + } @http_get("/roles", response=List[schemas.RoleSchema]) def list_roles(self): @@ -43,11 +63,9 @@ def list_roles(self): # Groupes @http_post( - "/groups", + "/groups", response=schemas.GroupSchema, - permissions=[ - IsAuthenticated & access_manager('w') - ] + permissions=[IsAuthenticated & access_manager("write")], ) def create_group(self, group_data: schemas.GroupCreateSchema): """ @@ -56,7 +74,7 @@ def create_group(self, group_data: schemas.GroupCreateSchema): return self.service.create_group(group_data) @http_get( - "/groups", + "/groups", response=List[schemas.GroupSchema], ) def list_groups(self, app: Optional[str] = None): @@ -66,7 +84,7 @@ def list_groups(self, app: Optional[str] = None): return self.service.get_groups(app) @http_get( - "/groups/{group_slug}", + "/groups/{group_slug}", response=schemas.GroupSchema, ) def get_group(self, group_slug: str): @@ -76,30 +94,22 @@ def get_group(self, group_slug: str): return self.service.get_group(group_slug) @http_put( - "/groups/{group_slug}", + "/groups/{group_slug}", response=schemas.GroupSchema, - permissions=[ - IsAuthenticated & access_manager('ru') - ] + permissions=[IsAuthenticated & access_manager("read/update")], ) def update_group(self, group_slug: str, group_data: schemas.GroupUpdateSchema): """ Met à jour un groupe existant. """ return self.service.update_group( - group_slug, - group_data.dict(exclude_unset=True, exclude={"roles"}), - group_data.roles + group_slug, group_data.dict(exclude_unset=True, exclude={"roles"}), group_data.roles ) @http_delete( - "/groups/{group_slug}", - response={ - 204: None - }, - permissions=[ - IsAuthenticated & access_manager('d') - ] + "/groups/{group_slug}", + response={204: None}, + permissions=[IsAuthenticated & access_manager("delete")], ) def delete_group(self, group_slug: str): """ @@ -111,9 +121,7 @@ def delete_group(self, group_slug: str): @http_get( "/groups/{group_slug}/members", response=List[schemas.GroupMemberSchema], - permissions=[ - IsAuthenticated & access_manager('r') - ] + permissions=[IsAuthenticated & access_manager("read")], ) def get_group_members(self, group_slug: str): """ @@ -121,13 +129,11 @@ def get_group_members(self, group_slug: str): """ return self.service.get_group_members(group_slug) - # Rôles des utilisateurs + # Rôles des utilisateurs @http_post( "/users/assign-role", response=schemas.RoleSchema, - permissions=[ - IsAuthenticated & access_manager('rw') - ] + permissions=[IsAuthenticated & access_manager("read/write")], ) def assign_role_to_user(self, data: schemas.AssignRoleSchema, request: HttpRequest): """ @@ -137,37 +143,27 @@ def assign_role_to_user(self, data: schemas.AssignRoleSchema, request: HttpReque user_id=data.user_id, role_slug=data.role, scope=data.scope, - by_user=request.user if request.user.is_authenticated else None + by_user=request.user if request.user.is_authenticated else None, ) @http_post( - "/users/revoke-role", - response={ - 204: None - }, - permissions=[ - IsAuthenticated & access_manager('rw') - ] + "/users/revoke-role", + response={204: None}, + permissions=[IsAuthenticated & access_manager("read/write")], ) def revoke_role_from_user(self, data: schemas.RevokeRoleSchema): """ Révoque un rôle d'un utilisateur. """ self.service.revoke_role_from_user( - user_id=data.user_id, - role_slug=data.role, - scope=data.scope + user_id=data.user_id, role_slug=data.role, scope=data.scope ) return None @http_post( "/users/override-grant", - response={ - 204: None - }, - permissions=[ - IsAuthenticated & access_manager('rw') - ] + response={204: None}, + permissions=[IsAuthenticated & access_manager("read/write")], ) def override_grant_for_user(self, data: schemas.OverrideGrantSchema): """ @@ -175,19 +171,14 @@ def override_grant_for_user(self, data: schemas.OverrideGrantSchema): Si actions est vide, le grant est supprimé. """ self.service.override_grant_for_user( - user_id=data.user_id, - scope=data.scope, - actions=data.actions, - role=data.role + user_id=data.user_id, scope=data.scope, actions=data.actions, role=data.role ) return None @http_post( "/users/assign-group", response=List[schemas.RoleSchema], - permissions=[ - IsAuthenticated & access_manager('rw') - ] + permissions=[IsAuthenticated & access_manager("read/write")], ) def assign_group_to_user(self, data: schemas.AssignGroupSchema, request: HttpRequest): """ @@ -196,36 +187,29 @@ def assign_group_to_user(self, data: schemas.AssignGroupSchema, request: HttpReq return self.service.assign_group_to_user( user_id=data.user_id, group_slug=data.group, - by_user=request.user if request.user.is_authenticated else None + by_user=request.user if request.user.is_authenticated else None, ) @http_post( - "/users/revoke-group", - response={ - 204: None - }, - permissions=[ - IsAuthenticated & access_manager('rw') - ] + "/users/revoke-group", + response={204: None}, + permissions=[IsAuthenticated & access_manager("read/write")], ) def revoke_group_from_user(self, data: schemas.RevokeGroupSchema): """ Révoque un groupe de rôles d'un utilisateur. """ - self.service.revoke_group_from_user( - user_id=data.user_id, - group_slug=data.group - ) + self.service.revoke_group_from_user(user_id=data.user_id, group_slug=data.group) return None @http_get( "/users/{user_id}/grants", response=List[schemas.GrantSchema], - permissions=[ - IsAuthenticated & access_manager('r') - ] + permissions=[IsAuthenticated & access_manager("read")], ) - def get_user_grants(self, user_id: UUID, scope: Optional[str] = None, app: Optional[str] = None): + def get_user_grants( + self, user_id: UUID, scope: Optional[str] = None, app: Optional[str] = None + ): """ Récupère tous les grants d'un utilisateur. """ @@ -234,9 +218,7 @@ def get_user_grants(self, user_id: UUID, scope: Optional[str] = None, app: Optio @http_get( "/users/{user_id}/groups", response=List[schemas.GroupSchema], - permissions=[ - IsAuthenticated & access_manager('r') - ] + permissions=[IsAuthenticated & access_manager("read")], ) def get_user_groups(self, user_id: UUID): """ @@ -245,11 +227,9 @@ def get_user_groups(self, user_id: UUID): return self.service.get_user_groups(user_id=user_id) @http_put( - "/grants/{grant_id}", + "/grants/{grant_id}", response=schemas.GrantSchema, - permissions=[ - IsAuthenticated & access_manager('ru') - ] + permissions=[IsAuthenticated & access_manager("read/update")], ) def update_grant(self, grant_id: int, grant_data: schemas.GrantUpdateSchema): """ @@ -259,11 +239,9 @@ def update_grant(self, grant_id: int, grant_data: schemas.GrantUpdateSchema): # Role Grants @http_post( - "/role-grants", + "/role-grants", response=schemas.RoleGrantSchema, - permissions=[ - IsAuthenticated & access_manager('rw') - ] + permissions=[IsAuthenticated & access_manager("read/write")], ) def create_role_grant(self, grant_data: schemas.RoleGrantCreateSchema): """ @@ -272,7 +250,7 @@ def create_role_grant(self, grant_data: schemas.RoleGrantCreateSchema): return self.service.create_role_grant(grant_data) @http_get( - "/role-grants", + "/role-grants", response=List[schemas.RoleGrantSchema], ) def list_role_grants(self, app: Optional[str] = None): @@ -282,11 +260,9 @@ def list_role_grants(self, app: Optional[str] = None): return self.service.get_role_grants(app) @http_put( - "/role-grants/{grant_id}", + "/role-grants/{grant_id}", response=schemas.RoleGrantSchema, - permissions=[ - IsAuthenticated & access_manager('ru') - ] + permissions=[IsAuthenticated & access_manager("read/update")], ) def update_role_grant(self, grant_id: int, grant_data: schemas.RoleGrantUpdateSchema): """ @@ -295,13 +271,9 @@ def update_role_grant(self, grant_id: int, grant_data: schemas.RoleGrantUpdateSc return self.service.update_role_grant(grant_id, grant_data) @http_delete( - "/role-grants/{grant_id}/", - response={ - 204: None - }, - permissions=[ - IsAuthenticated & access_manager('d') - ] + "/role-grants/{grant_id}/", + response={204: None}, + permissions=[IsAuthenticated & access_manager("delete")], ) def delete_role_grant(self, grant_id: int): """ diff --git a/src/oxutils/permissions/locale/fr/LC_MESSAGES/django.po b/src/oxutils/permissions/locale/fr/LC_MESSAGES/django.po index 1dbae7e..cc22635 100644 --- a/src/oxutils/permissions/locale/fr/LC_MESSAGES/django.po +++ b/src/oxutils/permissions/locale/fr/LC_MESSAGES/django.po @@ -1,22 +1,21 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. +# OxUtils Permissions — French translations. +# Copyright (C) 2026 Oxiliere +# This file is distributed under the LGPL-3.0 license. # -#, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-16 23:11+0200\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" +"Project-Id-Version: oxutils 0.5.0\n" +"Report-Msgid-Bugs-To: dev@oxiliere.com\n" +"POT-Creation-Date: 2026-07-18 00:00+0000\n" +"PO-Revision-Date: 2026-07-18 00:00+0000\n" +"Last-Translator: Oxiliere Team \n" +"Language-Team: French\n" +"Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" + #: exceptions.py:14 msgid "The requested role does not exist" msgstr "Le rôle demandé n'existe pas" @@ -60,3 +59,20 @@ msgstr "Un grant existe déjà pour cet utilisateur et ce scope" #: services.py:310 services.py:314 msgid "Application is not allowed" msgstr "L'application n'est pas autorisée" + +#: permissions.py (access scope actions) +msgid "Read" +msgstr "Lire" + +msgid "Write" +msgstr "Écrire" + +msgid "Update" +msgstr "Modifier" + +msgid "Delete" +msgstr "Supprimer" + +#: permissions.py (scope labels) +msgid "Access Management" +msgstr "Gestion des accès" diff --git a/src/oxutils/permissions/migrations/0010_increase_action_max_length.py b/src/oxutils/permissions/migrations/0010_increase_action_max_length.py new file mode 100644 index 0000000..7ccb7e0 --- /dev/null +++ b/src/oxutils/permissions/migrations/0010_increase_action_max_length.py @@ -0,0 +1,30 @@ +# Generated manually — increases action max_length for named actions. + +from django.db import migrations, models +import django.contrib.postgres.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("permissions", "0009_grant_is_active"), + ] + + operations = [ + migrations.AlterField( + model_name="grant", + name="actions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=50), + size=None, + ), + ), + migrations.AlterField( + model_name="rolegrant", + name="actions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=50), + size=None, + ), + ), + ] diff --git a/src/oxutils/permissions/models.py b/src/oxutils/permissions/models.py index 744f64b..753d0ab 100644 --- a/src/oxutils/permissions/models.py +++ b/src/oxutils/permissions/models.py @@ -81,11 +81,11 @@ class RoleGrant(models.Model): role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="grants") scope = models.CharField(max_length=100) - actions = ArrayField(models.CharField(max_length=5)) + actions = ArrayField(models.CharField(max_length=50)) context = models.JSONField(default=dict, blank=True) def clean(self): - self.actions = expand_actions(self.actions) + self.actions = expand_actions(self.scope, self.actions) class Meta: constraints = [models.UniqueConstraint(fields=["role", "scope"], name="unique_role_scope")] @@ -146,7 +146,7 @@ class Grant(TimestampMixin): ) scope = models.CharField(max_length=100) - actions = ArrayField(models.CharField(max_length=5)) + actions = ArrayField(models.CharField(max_length=50)) context = models.JSONField(default=dict, blank=True) is_active = models.BooleanField(default=True) diff --git a/src/oxutils/permissions/permissions.py b/src/oxutils/permissions/permissions.py new file mode 100644 index 0000000..433b968 --- /dev/null +++ b/src/oxutils/permissions/permissions.py @@ -0,0 +1,45 @@ +""" +Permission preset for the built-in ``access`` scope. + +This module is **auto-discovered** by ``PermissionsConfig.ready()`` +at Django startup. It defines the named actions used internally by +the permission management endpoints (e.g. ``/api/access/``). + +Labels use ``gettext_lazy`` so the frontend can display action names +in the active language. +""" + +from django.utils.translation import gettext_lazy as _ + +# ── Actions used internally by the permissions module ──────────────── +# The ``access`` scope is strictly owned by ``oxutils.permissions``. +PERMISSION_PRESET = { + "actions": { + "access": { + "read": { + "implies": [], + "label": _("Read"), + }, + "write": { + "implies": ["read"], + "label": _("Write"), + }, + "update": { + "implies": ["read"], + "label": _("Update"), + }, + "delete": { + "implies": ["read", "write"], + "label": _("Delete"), + }, + }, + }, + "roles": [], + "groups": [], + "role_grants": [], +} + +# Scopes used by this module (with translatable labels) +ACCESS_SCOPES = [ + {"key": "access", "label": _("Access Management")}, +] diff --git a/src/oxutils/permissions/perms.py b/src/oxutils/permissions/perms.py index f3519c8..d6515a2 100644 --- a/src/oxutils/permissions/perms.py +++ b/src/oxutils/permissions/perms.py @@ -13,17 +13,30 @@ class ScopePermission(BasePermission): """ - Permission class for checking user permissions using the string format. + Permission class for checking user permissions using named actions. - Format: ":" or "::?key=value" + Format: + ``:`` — single action + ``:/`` — AND (all actions required) + ``:|`` — OR (at least one action) + ``:/:`` — AND with role filter + ``:|:`` — OR with role filter + ``:/?key=value`` — with context Example: - @api_controller('/articles', permissions=[ScopePermission('articles:w')]) - class ArticleController: + @api_controller('/orders', permissions=[ScopePermission('orders:create/approve')]) + class OrderController: + # User needs create AND approve on orders pass - @api_controller('/articles', permissions=[ScopePermission('articles:w:editor')]) + @api_controller('/orders', permissions=[ScopePermission('orders:create|approve')]) + class OrderController: + # User needs create OR approve on orders + pass + + @api_controller('/articles', permissions=[ScopePermission('articles:publish:editor')]) class EditorArticleController: + # User needs publish via editor role on articles pass """ @@ -32,7 +45,10 @@ def __init__(self, perm: str, ctx: Optional[dict] = None): Initialize the permission checker. Args: - perm: Permission string in format ":" or "::?context" + perm: Permission string in format + ``:/[:][?context]`` or + ``:|[:][?context]`` + ctx: Optional additional context dict (merged with query params). """ self.perm = perm self.ctx = ctx if ctx else dict() @@ -41,12 +57,7 @@ def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bo """ Check if the user has the required permission. - Args: - request: HTTP request object - controller: Controller instance - - Returns: - True if user has permission, False otherwise + Handles both AND (``/``) and OR (``|``) operators automatically. """ return str_check(request.user, self.perm, **self.ctx) @@ -55,15 +66,15 @@ class ScopeAnyPermission(BasePermission): """ Permission class for checking if user has at least one of multiple permissions. - Vérifie si l'utilisateur possède au moins une des permissions fournies. - Utilise any_permission_check pour une vérification optimisée en une seule requête. + Each permission string can use ``/`` (AND) or ``|`` (OR) internally, + and the overall check is OR across all provided strings. Example: - @api_controller('/articles', permissions=[ - ScopeAnyPermission('articles:r', 'articles:w:editor', 'articles:d:admin') + @api_controller('/orders', permissions=[ + ScopeAnyPermission('orders:create|approve', 'articles:read') ]) - class ArticleController: - # User needs either read access, OR editor write access, OR admin delete access + class MultiScopeController: + # User needs (create OR approve on orders) OR (read on articles) pass """ @@ -72,7 +83,7 @@ def __init__(self, *perms: str): Initialize the permission checker with multiple permission strings. Args: - *perms: Variable number of permission strings in format ":" or "::?context" + *perms: Variable number of permission strings. """ if not perms: raise ValueError("At least one permission string must be provided") @@ -81,13 +92,6 @@ def __init__(self, *perms: str): def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: """ Check if the user has at least one of the required permissions. - - Args: - request: HTTP request object - controller: Controller instance - - Returns: - True if user has at least one permission, False otherwise """ from oxutils.permissions.caches import cache_any_permission_check @@ -96,24 +100,26 @@ def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bo class ScopeAnyActionPermission(BasePermission): """ - Permission class for checking if user has at least one of multiple actions on a scope. + Permission class for checking if user has at least one of multiple actions + on a **single scope** (OR semantics). - Vérifie si l'utilisateur possède au moins une des actions requises pour un scope donné. - La chaîne d'actions contient plusieurs actions dont au moins une est requise. + This class forces OR semantics regardless of the separator in the string. + Use ``ScopePermission`` with ``|`` separator if you want inline OR, + or this class if you prefer explicit semantics. Example: - @api_controller('/articles', permissions=[ - ScopeAnyActionPermission('articles:rwd') + @api_controller('/orders', permissions=[ + ScopeAnyActionPermission('orders:create/approve') ]) - class ArticleController: - # User needs read OR write OR delete access on articles + class OrderController: + # User needs create OR approve on orders (OR despite '/') pass - @api_controller('/invoices', permissions=[ - ScopeAnyActionPermission('invoices:rw:accountant') + @api_controller('/orders', permissions=[ + ScopeAnyActionPermission('orders:create|approve:manager') ]) - class InvoiceController: - # User needs read OR write access on invoices via accountant role + class ManagerOrderController: + # Same as above but with role filter pass """ @@ -122,9 +128,8 @@ def __init__(self, perm: str, ctx: Optional[dict] = None): Initialize the permission checker with a permission string. Args: - perm: Permission string in format ":" or "::?context" - where actions contains multiple characters (e.g., 'rwd' for read OR write OR delete) - ctx: Optional additional context dict + perm: Permission string. Regardless of separator, OR is used. + ctx: Optional additional context dict. """ if not perm: raise ValueError("Permission string must be provided") @@ -134,21 +139,15 @@ def __init__(self, perm: str, ctx: Optional[dict] = None): def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: """ - Check if the user has at least one of the required actions. - - Args: - request: HTTP request object - controller: Controller instance - - Returns: - True if user has at least one action, False otherwise + Check if the user has at least one of the required actions (always OR). """ from oxutils.permissions.caches import cache_any_action_check from oxutils.permissions.utils import parse_permission - scope, actions, role, query_context = parse_permission(self.perm) + scope, actions, _operator, role, query_context = parse_permission(self.perm) final_context = {**query_context, **self.ctx} + # Always OR regardless of the separator in the string return cache_any_action_check(request.user, scope, actions, role=role, **final_context) @@ -156,44 +155,49 @@ def access_manager(actions: str): """ Factory function for creating ScopePermission instances for access manager. - Builds a permission string from settings: - - ACCESS_MANAGER_SCOPE: The scope to check - - ACCESS_MANAGER_GROUP: Optional group for UserGroup assignment (used in authorization) + Uses settings with a default for the scope (no mandatory config): + - ACCESS_MANAGER_SCOPE: The scope to check (default ``"access"``) + - ACCESS_MANAGER_GROUP: Optional group for UserGroup assignment - ACCESS_MANAGER_ROLE: Optional role filter for permission checks - ACCESS_MANAGER_CONTEXT: Optional context dict converted to query params Args: - actions: Actions required (e.g., 'r', 'rw', 'rwd') + actions: Actions required. + Use ``/`` for AND (e.g., ``'create/approve'``) + or ``|`` for OR (e.g., ``'create|approve'``) Returns: ScopePermission instance configured with access manager settings Raises: - ImproperlyConfigured: If required settings are missing + ImproperlyConfigured: If ACCESS_MANAGER_CONTEXT is not a dict. Example: - @api_controller('/access', permissions=[access_manager('w')]) + @api_controller('/access', permissions=[access_manager('write')]) class AccessController: pass + + @api_controller('/access', permissions=[access_manager('read/write')]) + class AdvancedAccessController: + # User needs both read AND write on access scope + pass """ - # Validate required settings - if not hasattr(settings, "ACCESS_MANAGER_SCOPE"): - raise ImproperlyConfigured( - "ACCESS_MANAGER_SCOPE is not defined. " - 'Add ACCESS_MANAGER_SCOPE = "access" to your settings.' - ) + # Scope defaults to "access" — the module owns this scope + scope = getattr(settings, "ACCESS_MANAGER_SCOPE", "access") + role = getattr(settings, "ACCESS_MANAGER_ROLE", None) + ctx = getattr(settings, "ACCESS_MANAGER_CONTEXT", None) # Build base permission string: scope:actions - perm = f"{settings.ACCESS_MANAGER_SCOPE}:{actions}" + perm = f"{scope}:{actions}" # Add role if defined and not None - if hasattr(settings, "ACCESS_MANAGER_ROLE") and settings.ACCESS_MANAGER_ROLE is not None: - perm += f":{settings.ACCESS_MANAGER_ROLE}" + if role is not None: + perm += f":{role}" # Get context if defined and not empty context = {} - if hasattr(settings, "ACCESS_MANAGER_CONTEXT") and settings.ACCESS_MANAGER_CONTEXT: - context = settings.ACCESS_MANAGER_CONTEXT + if ctx: + context = ctx if not isinstance(context, dict): raise ImproperlyConfigured( "ACCESS_MANAGER_CONTEXT must be a dictionary. " @@ -237,7 +241,7 @@ def has_permission(self, request, controller): @api_controller( "/api", - permissions=[*extra_permissions(), ScopePermission("articles:r")], + permissions=[*extra_permissions(), ScopePermission("articles:read")], ) class MyController: ... diff --git a/src/oxutils/permissions/presets.py b/src/oxutils/permissions/presets.py index ba67bab..0f6c94f 100644 --- a/src/oxutils/permissions/presets.py +++ b/src/oxutils/permissions/presets.py @@ -37,6 +37,10 @@ def discover_app_presets() -> list[dict]: for grant in preset.get("role_grants", []): grant.setdefault("app", app_config.label) + # Tag the preset with its originating app label (used for + # scope-ownership validation and error messages). + preset["_app_label"] = app_config.label + presets.append(preset) logger.debug( "permission_preset_discovered", @@ -49,9 +53,31 @@ def discover_app_presets() -> list[dict]: return presets -def discover_access_scopes() -> list[str]: - """Walk installed apps and collect their ``ACCESS_SCOPES``.""" - scopes: list[str] = [] +def _normalize_scope(entry) -> dict: + """Normalize a scope entry to ``{"key": str, "label": lazy str}``. + + The label is kept as a lazy string when provided — it will be + resolved at request time when the frontend calls the endpoint. + """ + if isinstance(entry, str): + return {"key": entry, "label": entry} + if isinstance(entry, dict): + key = entry.get("key", "") + label = entry.get("label") + return {"key": str(key), "label": label if label else str(key)} + return {"key": str(entry), "label": str(entry)} + + +def discover_access_scopes() -> list[dict]: + """Walk installed apps and collect their ``ACCESS_SCOPES``. + + Returns a list of normalized scope dicts ``{"key", "label"}``. + Each entry in ``ACCESS_SCOPES`` can be a plain string (key only) + or a dict with ``key`` and ``label`` (translatable). + """ + scopes: list[dict] = [] + seen_keys: set[str] = set() + for app_config in apps.get_app_configs(): try: mod = importlib.import_module(f"{app_config.name}.permissions") @@ -59,15 +85,20 @@ def discover_access_scopes() -> list[str]: continue app_scopes = getattr(mod, "ACCESS_SCOPES", None) - if isinstance(app_scopes, list): - for scope in app_scopes: - if scope not in scopes: - scopes.append(scope) - logger.debug( - "access_scope_discovered", - app=app_config.label, - scope=scope, - ) + if not isinstance(app_scopes, list): + continue + + for entry in app_scopes: + normalized = _normalize_scope(entry) + key = normalized["key"] + if key and key not in seen_keys: + seen_keys.add(key) + scopes.append(normalized) + logger.debug( + "access_scope_discovered", + app=app_config.label, + scope=key, + ) return scopes @@ -94,29 +125,79 @@ def discover_access_applications() -> list[str]: def register_preset(base_preset: dict) -> dict: - """Extend *base_preset* with presets discovered from installed apps.""" + """Extend *base_preset* with presets discovered from installed apps. + + **Scope ownership** — each scope in ``actions`` is strictly owned by + the first app that defines it. If a second app tries to define the + same scope, ``ImproperlyConfigured`` is raised immediately. + """ + from django.core.exceptions import ImproperlyConfigured + base_roles = base_preset.setdefault("roles", []) base_groups = base_preset.setdefault("groups", []) base_grants = base_preset.setdefault("role_grants", []) + base_actions = base_preset.setdefault("actions", {}) + + # ── track scope ownership ────────────────────────────────────── + # The base preset (settings.py) owns the scopes it defines. + scope_owners: dict[str, str] = {} + for scope in base_actions: + scope_owners[scope] = "settings.PERMISSION_PRESET" for preset in discover_app_presets(): + app_label = preset.get("_app_label", "unknown") + base_roles.extend(preset.get("roles", [])) base_groups.extend(preset.get("groups", [])) base_grants.extend(preset.get("role_grants", [])) - return base_preset + # ── strict scope ownership ─────────────────────────────── + for scope in preset.get("actions", {}): + if scope in scope_owners: + raise ImproperlyConfigured( + f"Scope '{scope}' is already owned by '{scope_owners[scope]}'. " + f"App '{app_label}' cannot redefine it. " + f"Each scope must be owned by exactly one app." + ) + scope_owners[scope] = app_label + # Copy the scope's action definitions (no merging needed — + # we already verified there's no conflict). + base_actions[scope] = dict(preset["actions"][scope]) -def register_access_scopes() -> None: - """Extend ``settings.ACCESS_SCOPES`` with scopes discovered from installed apps.""" - existing = list(getattr(settings, "ACCESS_SCOPES", [])) + return base_preset - for scope in discover_access_scopes(): - if scope not in existing: - existing.append(scope) - settings.ACCESS_SCOPES = existing - logger.debug("access_scopes_registered", count=len(existing)) +def register_access_scopes() -> None: + """Extend ``settings.ACCESS_SCOPES`` with scopes discovered from apps. + + Merges normalized scope dicts. If ``settings.ACCESS_SCOPES`` is a list + of plain strings they are normalized first. When a scope appears both + as a plain string and as a dict, the **dict wins** (preserves the label). + """ + merged: dict[str, dict] = {} + + # Process settings-based scopes first (strings override nothing) + raw = getattr(settings, "ACCESS_SCOPES", []) + if isinstance(raw, list): + for entry in raw: + norm = _normalize_scope(entry) + key = norm["key"] + if key: + merged.setdefault(key, norm) + + # Discovered scopes (from app permissions.py) may have labels — + # they override plain-string entries with the same key. + for scope_dict in discover_access_scopes(): + key = scope_dict["key"] + if key: + existing = merged.get(key) + # Dict wins over plain string (preserves the translatable label) + if existing is None or ("label" in scope_dict and scope_dict["label"] != key): + merged[key] = scope_dict + + settings.ACCESS_SCOPES = list(merged.values()) + logger.debug("access_scopes_registered", count=len(merged)) def register_access_applications() -> None: diff --git a/src/oxutils/permissions/schemas.py b/src/oxutils/permissions/schemas.py index 1aa1ef3..6af906c 100644 --- a/src/oxutils/permissions/schemas.py +++ b/src/oxutils/permissions/schemas.py @@ -8,12 +8,30 @@ from oxutils.oxiliere.schemas import UserSchema -from .actions import ACTIONS +from .actions import get_all_valid_actions + + +def _get_valid_scope_keys() -> set[str]: + """Return the set of valid scope keys from ACCESS_SCOPES.""" + scopes = getattr(settings, "ACCESS_SCOPES", []) + if not isinstance(scopes, list): + return set() + keys: set[str] = set() + for entry in scopes: + if isinstance(entry, dict): + keys.add(entry.get("key", "")) + else: + keys.add(str(entry)) + return keys def validate_actions_list(actions: list[str]) -> list[str]: """ - Valide qu'une liste d'actions contient uniquement des actions valides. + Valide qu'une liste d'actions contient uniquement des actions déclarées + dans le preset ``PERMISSION_PRESET["actions"]``. + + Si aucun preset d'actions n'est défini, la validation est permissive + (toutes les actions sont acceptées). Args: actions: Liste des actions à valider @@ -27,9 +45,18 @@ def validate_actions_list(actions: list[str]) -> list[str]: if not actions: raise ValueError("Les actions ne peuvent pas être vides") - invalid_actions = [a for a in actions if a not in ACTIONS] + valid_actions = get_all_valid_actions() + + # Permissive mode: if no actions are defined in the preset, accept anything + if not valid_actions: + return actions + + invalid_actions = [a for a in actions if a not in valid_actions] if invalid_actions: - raise ValueError(f"Actions invalides: {invalid_actions}. Actions valides: {ACTIONS}") + raise ValueError( + f"Actions invalides: {invalid_actions}. " + f"Actions déclarées dans le preset: {sorted(valid_actions)}" + ) return actions @@ -278,9 +305,7 @@ class AssignRoleSchema(Schema): @classmethod def validate_scope(cls, v: str) -> str: """Valide que le scope est valide.""" - scopes = getattr(settings, "ACCESS_SCOPES", []) - - if v not in scopes: + if v not in _get_valid_scope_keys(): raise ValueError(f"Invalid scope '{v}'") return v @@ -299,9 +324,7 @@ class OverrideGrantSchema(Schema): @classmethod def validate_scope(cls, v: str) -> str: """Valide que le scope est valide.""" - scopes = getattr(settings, "ACCESS_SCOPES", []) - - if v not in scopes: + if v not in _get_valid_scope_keys(): raise ValueError(f"Invalid scope '{v}'") return v @@ -325,9 +348,7 @@ class RevokeRoleSchema(Schema): @classmethod def validate_scope(cls, v: str) -> str: """Valide que le scope est valide.""" - scopes = getattr(settings, "ACCESS_SCOPES", []) - - if v not in scopes: + if v not in _get_valid_scope_keys(): raise ValueError(f"Invalid scope '{v}'") return v @@ -368,3 +389,30 @@ class PresetLoadResponseSchema(Schema): groups_created: int role_grants_created: int message: str = "Preset chargé avec succès" + + +class ActionLabelSchema(Schema): + """ + Schéma pour une action avec son label traduit. + """ + + key: str + label: str + + +class ScopeActionsResponseSchema(Schema): + """ + Schéma pour la réponse listant les actions d'un scope avec leurs labels. + """ + + scope: str + actions: list[ActionLabelSchema] + + +class ScopeSchema(Schema): + """ + Schéma pour un scope avec son label (affichage frontend). + """ + + key: str + label: str diff --git a/src/oxutils/permissions/utils.py b/src/oxutils/permissions/utils.py index 6dad854..433371f 100644 --- a/src/oxutils/permissions/utils.py +++ b/src/oxutils/permissions/utils.py @@ -50,7 +50,7 @@ def assign_role( scope=rg.scope, role=role_obj, defaults={ - "actions": expand_actions(rg.actions), + "actions": expand_actions(rg.scope, rg.actions), "context": rg.context, "user_group": user_group, "created_by": by, @@ -208,7 +208,7 @@ def override_grant( return # Expander et définir les nouvelles actions - expanded_actions = expand_actions(actions) + expanded_actions = expand_actions(scope, actions) grant.actions = expanded_actions grant.locked = True # Le grant devient verrouillé (protégé du group_sync) grant.save(update_fields=["actions", "locked", "updated_at"]) @@ -324,7 +324,7 @@ def group_sync( user=user_group.user, scope=rg.scope, role=rg.role, - actions=expand_actions(rg.actions), + actions=expand_actions(rg.scope, rg.actions), context=rg.context, user_group=user_group, ) @@ -419,7 +419,7 @@ def role_sync(role_slug: str, scope: Optional[str] = None) -> dict[str, int]: if role_grant: # Mettre à jour les actions et le contexte directement - grant.actions = expand_actions(role_grant.actions) + grant.actions = expand_actions(role_grant.scope, role_grant.actions) grant.context = role_grant.context grant.save(update_fields=["actions", "context", "updated_at"]) updated_count += 1 @@ -435,14 +435,14 @@ def check( **context: Any, ) -> bool: """ - Vérifie si un utilisateur possède les permissions requises pour un scope donné. + Vérifie si un utilisateur possède **toutes** les actions requises pour un scope donné (AND). Utilise l'opérateur PostgreSQL @> (contains) pour vérifier que toutes les actions - requises sont présentes dans le grant."updated_at" + requises sont présentes dans le grant. Args: user: L'utilisateur dont on vérifie les permissions - scope: Le scope à vérifier (ex: 'articles', 'users', 'comments') - required: Liste des actions requises (ex: ['r'], ['w', 'r'], ['d']) + scope: Le scope à vérifier (ex: 'orders', 'articles') + required: Liste des actions nommées requises (ex: ['create'], ['create', 'approve']) role: Slug du rôle optionnel pour filtrer les grants par rôle. Si None, vérifie globalement tous les grants du scope. **context: Contexte additionnel pour filtrer les grants (clés JSON) @@ -451,19 +451,20 @@ def check( True si l'utilisateur possède toutes les actions requises, False sinon Example: - >>> # Vérification globale : l'utilisateur peut-il lire les articles ? - >>> check(user, 'articles', ['r']) + >>> # Vérification globale : l'utilisateur peut-il créer des commandes ? + >>> check(user, 'orders', ['create']) True - >>> # Vérification par rôle : a-t-il ce droit via le rôle 'admin' ? - >>> check(user, 'articles', ['w'], role='admin') - True - >>> # Vérifier avec contexte - >>> check(user, 'articles', ['w'], tenant_id=123) + >>> # Vérification AND : doit pouvoir créer ET approuver + >>> check(user, 'orders', ['create', 'approve']) False + >>> # Vérification par rôle + >>> check(user, 'orders', ['create'], role='manager') + True Note: Les actions sont automatiquement expandées lors de la création du grant, - donc vérifier ['w'] vérifiera aussi ['r'] implicitement. + donc vérifier ['create'] fonctionne même si le grant stocke ['approve', 'create'] + (si 'approve' implique 'create' dans la hiérarchie). """ # Construire le filtre de base grant_filter = Q( @@ -493,15 +494,16 @@ def any_action_check( **context: Any, ) -> bool: """ - Vérifie si un utilisateur possède au moins une des actions requises pour un scope donné. + Vérifie si un utilisateur possède **au moins une** des actions requises pour un scope donné (OR). Cette fonction utilise une seule requête optimisée avec des conditions OR pour vérifier si l'utilisateur possède au moins une des actions dans la liste. Args: user: L'utilisateur dont on vérifie les permissions - scope: Le scope à vérifier (ex: 'articles', 'invoices') - required: Liste des actions dont au moins une est requise (ex: ['r', 'w'], ['d']) + scope: Le scope à vérifier (ex: 'orders', 'articles') + required: Liste des actions nommées dont au moins une est requise + (ex: ['create', 'approve']) role: Slug du rôle optionnel pour filtrer les grants par rôle. Si None, vérifie globalement tous les grants du scope. **context: Contexte additionnel pour filtrer les grants (clés JSON) @@ -510,20 +512,12 @@ def any_action_check( True si l'utilisateur possède au moins une des actions requises, False sinon Example: - >>> # Vérification globale - >>> any_action_check(user, 'articles', ['r', 'w']) - True + >>> # Vérification OR globale + >>> any_action_check(user, 'orders', ['create', 'approve']) + True # si l'utilisateur a create OU approve >>> # Vérification par rôle - >>> any_action_check(user, 'articles', ['r', 'w'], role='editor') + >>> any_action_check(user, 'orders', ['create', 'approve'], role='editor') True - >>> # Vérifier avec contexte - >>> any_action_check(user, 'articles', ['w', 'd'], tenant_id=123) - False - - Note: - Les actions sont automatiquement expandées lors de la création du grant, - donc si un grant contient ['w'], il contient aussi ['r'] implicitement. - Cette fonction vérifie si AU MOINS UNE des actions requises est présente. """ # Construire le filtre de base pour l'utilisateur et le scope grant_filter = Q(user__pk=user.pk, scope=scope, is_active=True) @@ -580,30 +574,30 @@ def any_permission_check(user: AbstractBaseUser, *str_perms: str) -> bool: optimisée avec des conditions OR pour vérifier si l'utilisateur possède au moins une des permissions. + Chaque chaîne de permission peut utiliser : + - ``/`` (AND) — toutes les actions sont requises + - ``|`` (OR) — au moins une action est requise + Args: user: L'utilisateur dont on vérifie les permissions - *str_perms: Liste de chaînes de permissions au format standard - (ex: 'articles:r', 'invoices:w:admin', 'users:d?tenant_id=123') + *str_perms: Liste de chaînes de permissions au format + ``:/:?key=value`` + ou ``:|:?key=value`` Returns: True si l'utilisateur possède au moins une des permissions, False sinon Example: >>> # Vérification globale - >>> any_permission_check(user, 'articles:r', 'invoices:w') + >>> any_permission_check(user, 'articles:read', 'invoices:write') True - >>> # Avec différents rôles et contextes + >>> # AND dans un scope, OR entre scopes >>> any_permission_check( ... user, - ... 'articles:w:editor', - ... 'invoices:r:accountant', - ... 'users:d?tenant_id=123' + ... 'orders:create/approve', # AND: doit avoir create ET approve + ... 'articles:read|write:editor', # OR: au moins read OU write via editor ... ) False - - Note: - Toute la vérification se fait au niveau de la base de données avec une seule - requête utilisant des conditions OR pour optimiser les performances. """ if not str_perms: return False @@ -615,11 +609,16 @@ def any_permission_check(user: AbstractBaseUser, *str_perms: str) -> bool: permission_filters = Q() for perm in str_perms: - # Parser la permission - scope, actions, role, context = parse_permission(perm) + # Parser la permission (nouveau format avec opérateur) + scope, actions, operator, role, context = parse_permission(perm) # Construire le filtre pour cette permission spécifique - perm_filter = Q(scope=scope, actions__overlap=actions) + if operator == "|": + # OR : au moins une action parmi la liste + perm_filter = Q(scope=scope, actions__overlap=actions) + else: + # AND : toutes les actions doivent être présentes + perm_filter = Q(scope=scope, actions__contains=actions) # Filtrer par rôle si spécifié if role: @@ -636,38 +635,43 @@ def any_permission_check(user: AbstractBaseUser, *str_perms: str) -> bool: return Grant.objects.filter(base_filter & permission_filters).exists() -def parse_permission(perm: str) -> tuple[str, list[str], Optional[str], dict[str, Any]]: +def parse_permission(perm: str) -> tuple[str, list[str], str, Optional[str], dict[str, Any]]: """ Parse une chaîne de permission et retourne ses composants. Formats supportés: - - ":" : vérification globale sur le scope - - "::" : vérification liée à un rôle spécifique - - ":?key=value" : vérification globale avec contexte - - "::?key=value" : vérification par rôle avec contexte + - ``:`` : action unique sur le scope + - ``:/`` : AND — toutes les actions requises + - ``:|`` : OR — au moins une action requise + - ``:/:`` : AND avec rôle + - ``:|:`` : OR avec rôle + - ``:/:?key=value`` : AND + rôle + contexte + - ``:|:?key=value`` : OR + rôle + contexte Args: - perm: Chaîne de permission au format "::?key=value&key2=value2" - - scope: Le scope (ex: 'articles') - - actions: Actions requises (ex: 'rw', 'r', 'rwdx') - - role: (Optionnel) Slug du rôle pour filtrer - - query params: (Optionnel) Contexte sous forme de query parameters + perm: Chaîne de permission. Returns: - Tuple contenant (scope, actions_list, role, context_dict) + Tuple ``(scope, actions_list, operator, role, context_dict)`` + + - *scope*: le scope (ex: ``"orders"``) + - *actions_list*: liste des actions nommées (ex: ``["create", "approve"]``) + - *operator*: ``"&"`` (AND) ou ``"|"`` (OR) + - *role*: slug du rôle ou ``None`` + - *context_dict*: dictionnaire de contexte extrait des query params Raises: ValueError: Si le format de la permission est invalide Example: - >>> parse_permission('articles:rw') - ('articles', ['r', 'w'], None, {}) - >>> parse_permission('articles:w:admin') - ('articles', ['w'], 'admin', {}) - >>> parse_permission('articles:rw?tenant_id=123&status=published') - ('articles', ['r', 'w'], None, {'tenant_id': 123, 'status': 'published'}) - >>> parse_permission('articles:w:editor?tenant_id=123') - ('articles', ['w'], 'editor', {'tenant_id': 123}) + >>> parse_permission('orders:create/approve') + ('orders', ['create', 'approve'], '&', None, {}) + >>> parse_permission('orders:create|approve') + ('orders', ['create', 'approve'], '|', None, {}) + >>> parse_permission('orders:create/approve:manager') + ('orders', ['create', 'approve'], '&', 'manager', {}) + >>> parse_permission('articles:read?tenant_id=42') + ('articles', ['read'], '&', None, {'tenant_id': 42}) """ # Séparer la partie principale des query params if "?" in perm: @@ -684,71 +688,84 @@ def parse_permission(perm: str) -> tuple[str, list[str], Optional[str], dict[str query_context[k] = int(v) else: main_part = perm - query_context = {} + query_context: dict[str, Any] = {} - # Parser la partie principale + # Parser la partie principale : scope:actions[:role] parts = main_part.split(":") if len(parts) < 2: raise ValueError( f"Format de permission invalide: '{perm}'. " - "Format attendu: ':' ou '::' " - "ou '::?key=value&key2=value2'" + "Format attendu: ':' ou ':/[:]' " + "ou ':|[:]' " + "avec ?key=value&key2=value2 optionnel" ) scope = parts[0] actions_str = parts[1] role = parts[2] if len(parts) > 2 else None - # Convertir la chaîne d'actions en liste - # 'rwd' -> ['r', 'w', 'd'] - actions_list = list(actions_str) + # Déterminer l'opérateur et splitter les actions + if "/" in actions_str and "|" not in actions_str: + operator = "&" # AND + actions_list = [a for a in actions_str.split("/") if a] + elif "|" in actions_str and "/" not in actions_str: + operator = "|" # OR + actions_list = [a for a in actions_str.split("|") if a] + elif "/" in actions_str and "|" in actions_str: + raise ValueError( + f"Format de permission ambigu: '{perm}'. " + "Utilisez soit '/' (AND) soit '|' (OR), pas les deux simultanément." + ) + else: + # Action unique (ni / ni |) + operator = "&" # par défaut : AND sur une seule action + actions_list = [actions_str] - return scope, actions_list, role, query_context + return scope, actions_list, operator, role, query_context def str_check(user: AbstractBaseUser, perm: str, **context: Any) -> bool: """ Vérifie si un utilisateur possède les permissions requises à partir d'une chaîne formatée. + La chaîne peut utiliser : + - ``/`` (AND) : toutes les actions sont requises + - ``|`` (OR) : au moins une action est requise + Args: user: L'utilisateur dont on vérifie les permissions - perm: Chaîne de permission au format "::?key=value&key2=value2" - - scope: Le scope à vérifier (ex: 'articles') - - actions: Actions requises (ex: 'rw', 'r', 'rwdx') - - role: (Optionnel) Slug du rôle pour filtrer - - query params: (Optionnel) Contexte sous forme de query parameters + perm: Chaîne de permission au format + ``:/:?key=value`` (AND) + ou ``:|:?key=value`` (OR) **context: Contexte additionnel pour filtrer les grants (fusionné avec les query params) Returns: True si l'utilisateur possède les permissions requises, False sinon Example: - >>> # Vérification globale - >>> str_check(user, 'articles:r') + >>> # Vérification AND : doit avoir create ET approve + >>> str_check(user, 'orders:create/approve') True - >>> # Vérification par rôle - >>> str_check(user, 'articles:w:admin') + >>> # Vérification OR : doit avoir create OU approve + >>> str_check(user, 'orders:create|approve') True - >>> # Avec contexte via query params - >>> str_check(user, 'articles:w?tenant_id=123&status=published') - False >>> # Avec rôle et contexte - >>> str_check(user, 'articles:w:editor?tenant_id=123') - True - >>> # Contexte mixte (query params + kwargs) - >>> str_check(user, 'articles:w?tenant_id=123', level=2) + >>> str_check(user, 'orders:create/approve:manager?tenant_id=42') False """ - from .caches import cache_check + from .caches import cache_check, cache_any_action_check - # Parser la chaîne de permission - scope, required, role, query_context = parse_permission(perm) + # Parser la chaîne de permission (nouveau format avec opérateur) + scope, required, operator, role, query_context = parse_permission(perm) # Fusionner les contextes (kwargs ont priorité sur query params) final_context = {**query_context, **context} - return cache_check(user, scope, required, role=role, **final_context) + if operator == "|": + return cache_any_action_check(user, scope, required, role=role, **final_context) + else: + return cache_check(user, scope, required, role=role, **final_context) def load_preset(*, force: bool = False) -> dict[str, int]: @@ -766,6 +783,13 @@ def load_preset(*, force: bool = False) -> dict[str, int]: Le preset doit être défini dans settings.PERMISSION_PRESET avec la structure suivante: PERMISSION_PRESET = { + "actions": { + "orders": { + "create": {"implies": []}, + "approve": {"implies": ["create"]}, + "cancel": {"implies": []}, + }, + }, "roles": [ { "name": "Accountant", @@ -809,6 +833,7 @@ def load_preset(*, force: bool = False) -> dict[str, int]: Returns: Dictionnaire avec les statistiques de création: { + "actions": nombre d'actions enregistrées (toujours 0, car les actions sont déclaratives), "roles": nombre de rôles créés, "groups": nombre de groupes créés, "role_grants": nombre de role_grants créés @@ -835,7 +860,7 @@ def load_preset(*, force: bool = False) -> dict[str, int]: "Attention : cela peut créer des doublons ou modifier les permissions existantes." ) - stats = {"roles": 0, "groups": 0, "role_grants": 0} + stats = {"actions": 0, "roles": 0, "groups": 0, "role_grants": 0} # Cache local pour éviter les requêtes répétées roles_cache: dict[str, Role] = {} diff --git a/tests/permissions/test_permissions.py b/tests/permissions/test_permissions.py index 912d692..cbda12a 100644 --- a/tests/permissions/test_permissions.py +++ b/tests/permissions/test_permissions.py @@ -1,5 +1,5 @@ """ -Tests for the permissions module. +Tests for the permissions module (refactored — named actions). """ import pytest from django.contrib.auth import get_user_model @@ -27,7 +27,11 @@ ) from oxutils.permissions.actions import ( collapse_actions, - expand_actions + expand_actions, + get_valid_actions, + get_implied_actions, + get_action_label, + get_scope_actions_labels, ) from oxutils.permissions.exceptions import ( RoleNotFoundException, @@ -107,7 +111,7 @@ def editor_role_grant(db_setup, editor_role): return RoleGrant.objects.create( role=editor_role, scope='articles', - actions=['r', 'w'], + actions=['read', 'write'], context={} ) @@ -118,1413 +122,1037 @@ def viewer_role_grant(db_setup, viewer_role): return RoleGrant.objects.create( role=viewer_role, scope='articles', - actions=['r'], + actions=['read'], context={} ) +# ── Actions expansion / collapse (named) ──────────────────────────── + class TestActionsExpansion: - """Test action expansion and collapse utilities.""" + """Test action expansion and collapse with named actions.""" def test_expand_actions_basic(self): - """Test basic action expansion.""" - assert set(expand_actions(['r'])) == {'r'} - assert set(expand_actions(['w'])) == {'r', 'w'} - assert set(expand_actions(['d'])) == {'r', 'w', 'd'} - assert set(expand_actions(['u'])) == {'r', 'u'} - assert set(expand_actions(['a'])) == {'a', 'r'} - - def test_expand_actions_multiple(self): - """Test expansion with multiple actions.""" - assert set(expand_actions(['r', 'w'])) == {'r', 'w'} - assert set(expand_actions(['r', 'd'])) == {'r', 'w', 'd'} - assert set(expand_actions(['w', 'u'])) == {'r', 'w', 'u'} - assert set(expand_actions(['a', 'w'])) == {'a', 'r', 'w'} + """Test basic action expansion with named actions.""" + # read has no implies → stays ['read'] + assert set(expand_actions('articles', ['read'])) == {'read'} + # write implies read + assert set(expand_actions('articles', ['write'])) == {'read', 'write'} + # delete implies read, write → also pulls delete + assert set(expand_actions('articles', ['delete'])) == {'delete', 'read', 'write'} + # update implies read + assert set(expand_actions('articles', ['update'])) == {'read', 'update'} + + def test_expand_actions_multi_level(self): + """Test multi-level expansion.""" + # publish → write → read + assert set(expand_actions('articles', ['publish'])) == {'publish', 'read', 'write'} + # archive → publish → write → read + assert set(expand_actions('articles', ['archive'])) == {'archive', 'publish', 'read', 'write'} + + def test_expand_actions_orders(self): + """Test expansion on orders scope.""" + # approve → create + assert set(expand_actions('orders', ['approve'])) == {'approve', 'create'} + # refund → approve → create + assert set(expand_actions('orders', ['refund'])) == {'approve', 'create', 'refund'} def test_collapse_actions(self): """Test action collapse to root actions.""" - assert set(collapse_actions(['r'])) == {'r'} - assert set(collapse_actions(['r', 'w'])) == {'w'} - assert set(collapse_actions(['r', 'w', 'd'])) == {'d'} - assert set(collapse_actions(['r', 'u'])) == {'u'} # u implies r, so only u remains - assert set(collapse_actions(['a', 'r'])) == {'a'} # a implies r, so only a remains - + assert collapse_actions('articles', ['read']) == {'read'} + assert collapse_actions('articles', ['read', 'write']) == {'write'} + assert collapse_actions('articles', ['read', 'write', 'delete']) == {'delete'} + assert collapse_actions('articles', ['read', 'update']) == {'update'} + assert collapse_actions('articles', ['publish', 'read', 'write']) == {'publish'} + + def test_collapse_actions_orders(self): + """Test collapse on orders scope.""" + assert collapse_actions('orders', ['approve', 'create']) == {'approve'} + assert collapse_actions('orders', ['refund', 'approve', 'create']) == {'refund'} + + def test_expand_unknown_action_noop(self): + """Expanding an action not in the preset is a no-op.""" + assert set(expand_actions('articles', ['unknown'])) == {'unknown'} + + def test_get_valid_actions(self): + """get_valid_actions returns the declared actions for a scope.""" + assert 'read' in get_valid_actions('articles') + assert 'write' in get_valid_actions('articles') + assert 'delete' in get_valid_actions('articles') + assert 'publish' in get_valid_actions('articles') + + def test_get_implied_actions(self): + """get_implied_actions returns the implied set.""" + assert get_implied_actions('articles', 'write') == {'read'} + assert get_implied_actions('articles', 'read') == set() + assert get_implied_actions('orders', 'approve') == {'create'} + + +class TestActionLabels: + """Test action label functions.""" + + def test_get_action_label_returns_label(self): + """get_action_label returns the label when defined.""" + assert get_action_label('orders', 'create') == 'Create' + assert get_action_label('orders', 'approve') == 'Approve' + assert get_action_label('articles', 'publish') == 'Publish' + + def test_get_action_label_falls_back_to_key(self): + """get_action_label returns the action key when no label is defined.""" + # If a scope has actions without labels, the key is returned + # This tests the fallback behavior on an action that exists + assert get_action_label('articles', 'read') == 'Read' + + def test_get_action_label_unknown_action(self): + """get_action_label returns the key itself for unknown actions.""" + assert get_action_label('orders', 'nonexistent') == 'nonexistent' + + def test_get_scope_actions_labels(self): + """get_scope_actions_labels returns all action→label mappings.""" + labels = get_scope_actions_labels('orders') + assert labels['create'] == 'Create' + assert labels['approve'] == 'Approve' + assert labels['cancel'] == 'Cancel' + assert labels['refund'] == 'Refund' + + +# ── Role assignment ────────────────────────────────────────────────── class TestRoleAssignment: - """Test role assignment and revocation.""" + """Test role assignment with named actions.""" def test_assign_role_creates_grants(self, test_user, editor_role, editor_role_grant, admin_user): - """Test that assigning a role creates appropriate grants.""" + """Test assign_role creates grants with expanded actions.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role) - assert grant is not None - assert set(grant.actions) == {'r', 'w'} - assert grant.created_by == admin_user - - def test_assign_role_not_found(self, test_user, admin_user): - """Test assigning a non-existent role raises exception.""" + grants = Grant.objects.filter(user=test_user, scope='articles') + assert grants.count() == 1 + grant = grants.first() + # write implies read, so grant should contain both + assert 'read' in grant.actions + assert 'write' in grant.actions + + def test_assign_role_not_found(self, test_user): + """Test assign_role raises exception for non-existent role.""" with pytest.raises(RoleNotFoundException): - assign_role(test_user, 'nonexistent', 'articles', by=admin_user) - - def test_assign_role_already_assigned(self, test_user, editor_role, editor_role_grant, admin_user): - """Test assigning an already assigned role creates duplicate grants.""" - assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Second assignment should work (creates duplicate grants) - assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Check we have grants - assert Grant.objects.filter(user=test_user, role=editor_role).count() >= 1 + assign_role(test_user, 'nonexistent', 'articles') def test_revoke_role(self, test_user, editor_role, editor_role_grant, admin_user): - """Test revoking a role removes grants.""" + """Test revoke_role removes grants.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - deleted_count, info = revoke_role(test_user, 'editor', 'articles') - - assert deleted_count > 0 - assert not Grant.objects.filter(user=test_user, role=editor_role).exists() + count, _ = revoke_role(test_user, 'editor', 'articles') + assert count > 0 + assert Grant.objects.filter(user=test_user, scope='articles').count() == 0 def test_revoke_role_not_found(self, test_user): - """Test revoking a non-existent role raises exception.""" + """Test revoke_role raises exception for non-existent role.""" with pytest.raises(RoleNotFoundException): revoke_role(test_user, 'nonexistent', 'articles') +# ── Group assignment ───────────────────────────────────────────────── + class TestGroupAssignment: - """Test group assignment and revocation.""" + """Test group assignment with named actions.""" def test_assign_group(self, test_user, staff_group, editor_role_grant, viewer_role_grant, admin_user): - """Test assigning a group creates grants for all roles.""" - user_group = assign_group(test_user, 'staff', by=admin_user) - - assert user_group is not None - assert user_group.user == test_user - assert user_group.group == staff_group - - # Check grants were created - grants = Grant.objects.filter(user=test_user, user_group=user_group) - assert grants.count() > 0 - - def test_assign_group_not_found(self, test_user, admin_user): - """Test assigning a non-existent group raises exception.""" + """Test assign_group creates UserGroup and grants.""" + ug = assign_group(test_user, 'staff', by=admin_user) + assert ug is not None + grants = Grant.objects.filter(user=test_user) + assert grants.count() >= 2 # at least editor + viewer grants + + def test_assign_group_not_found(self, test_user): with pytest.raises(GroupNotFoundException): - assign_group(test_user, 'nonexistent', by=admin_user) + assign_group(test_user, 'nonexistent') - def test_assign_group_already_assigned(self, test_user, staff_group, editor_role_grant, viewer_role_grant, admin_user): - """Test assigning an already assigned group raises exception.""" + def test_assign_group_already_assigned(self, test_user, staff_group, admin_user): assign_group(test_user, 'staff', by=admin_user) - with pytest.raises(GroupAlreadyAssignedException): assign_group(test_user, 'staff', by=admin_user) def test_revoke_group(self, test_user, staff_group, editor_role_grant, viewer_role_grant, admin_user): - """Test revoking a group removes all associated grants.""" - user_group = assign_group(test_user, 'staff', by=admin_user) - - deleted_count, info = revoke_group(test_user, 'staff') - - assert deleted_count > 0 - assert not UserGroup.objects.filter(user=test_user, group=staff_group).exists() - assert not Grant.objects.filter(user=test_user, user_group=user_group).exists() + """Test revoke_group removes UserGroup and grants.""" + assign_group(test_user, 'staff', by=admin_user) + count, _ = revoke_group(test_user, 'staff') + assert UserGroup.objects.filter(user=test_user).count() == 0 + +# ── Permission check ───────────────────────────────────────────────── class TestPermissionCheck: - """Test permission checking.""" + """Test permission check with named actions.""" def test_check_with_grant(self, test_user, editor_role, editor_role_grant, admin_user): - """Test checking permissions with existing grant.""" + """Test check returns True when user has the required actions.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - assert check(test_user, 'articles', ['r']) is True - assert check(test_user, 'articles', ['w']) is True - assert check(test_user, 'articles', ['d']) is False + assert check(test_user, 'articles', ['read']) is True + assert check(test_user, 'articles', ['write']) is True + assert check(test_user, 'articles', ['read', 'write']) is True # AND def test_check_without_grant(self, test_user): - """Test checking permissions without grant.""" - assert check(test_user, 'articles', ['r']) is False + """Test check returns False when user has no grant.""" + assert check(test_user, 'articles', ['read']) is False + + def test_check_and_logic(self, test_user, editor_role, editor_role_grant, admin_user): + """Test AND logic: all actions must be present.""" + assign_role(test_user, 'editor', 'articles', by=admin_user) + # User has read + write, but not delete + assert check(test_user, 'articles', ['read', 'delete']) is False def test_check_with_context(self, test_user, editor_role, admin_user): - """Test checking permissions with context.""" - # Create role grant with context + """Test check with context filtering.""" RoleGrant.objects.create( role=editor_role, scope='articles', - actions=['r', 'w'], - context={'tenant_id': 123} + actions=['read', 'write'], + context={'tenant_id': 42} ) - assign_role(test_user, 'editor', 'articles', by=admin_user) - - assert check(test_user, 'articles', ['r'], tenant_id=123) is True - assert check(test_user, 'articles', ['r'], tenant_id=456) is False + assert check(test_user, 'articles', ['read'], tenant_id=42) is True + assert check(test_user, 'articles', ['read'], tenant_id=99) is False - def test_check_with_role_filter(self, test_user, editor_role, editor_role_grant, admin_user): - """Test checking permissions with role filter.""" + def test_check_with_role_filter(self, test_user, editor_role, viewer_role, editor_role_grant, viewer_role_grant, admin_user): + """Test check filtered by role.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - assert check(test_user, 'articles', ['r'], role='editor') is True - assert check(test_user, 'articles', ['r'], role='nonexistent') is False + # Has writethrough editor + assert check(test_user, 'articles', ['read'], role='editor') is True + # Does NOT have read through viewer (viewer not assigned) + assert check(test_user, 'articles', ['read'], role='viewer') is False + +# ── String check ───────────────────────────────────────────────────── class TestStringCheck: - """Test string-based permission checking.""" + """Test string-based permission check with named actions.""" def test_str_check_basic(self, test_user, editor_role, editor_role_grant, admin_user): - """Test basic string check.""" + """Test str_check with AND format (default).""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - assert str_check(test_user, 'articles:r') is True - assert str_check(test_user, 'articles:w') is True - assert str_check(test_user, 'articles:d') is False + assert str_check(test_user, 'articles:read') is True + assert str_check(test_user, 'articles:read/write') is True # AND + assert str_check(test_user, 'articles:read/write/delete') is False # no delete + + def test_str_check_or_operator(self, test_user, editor_role, editor_role_grant, admin_user): + """Test str_check with | (OR) operator.""" + assign_role(test_user, 'editor', 'articles', by=admin_user) + # User has read + write, so OR checks pass + assert str_check(test_user, 'articles:read|write') is True + assert str_check(test_user, 'articles:read|delete') is True # has read + assert str_check(test_user, 'articles:delete|publish') is False # has neither def test_str_check_with_role(self, test_user, editor_role, editor_role_grant, admin_user): - """Test string check with role.""" + """Test str_check with role filter.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - assert str_check(test_user, 'articles:r:editor') is True - assert str_check(test_user, 'articles:r:nonexistent') is False + assert str_check(test_user, 'articles:read:editor') is True + assert str_check(test_user, 'articles:read:viewer') is False def test_str_check_with_context(self, test_user, editor_role, admin_user): - """Test string check with context query params.""" + """Test str_check with query string context.""" RoleGrant.objects.create( role=editor_role, scope='articles', - actions=['r', 'w'], - context={'tenant_id': 123} + actions=['read', 'write'], + context={'tenant_id': 42} ) - assign_role(test_user, 'editor', 'articles', by=admin_user) - - assert str_check(test_user, 'articles:r?tenant_id=123') is True - assert str_check(test_user, 'articles:r?tenant_id=456') is False + assert str_check(test_user, 'articles:read?tenant_id=42') is True + assert str_check(test_user, 'articles:read?tenant_id=99') is False def test_str_check_invalid_format(self, test_user): - """Test string check with invalid format.""" + """Test invalid format raises error.""" with pytest.raises(ValueError): - str_check(test_user, 'articles') # Missing actions + str_check(test_user, 'invalid') + + def test_str_check_mixed_separators_raises(self, test_user): + """Test that mixed / and | raises ValueError.""" + with pytest.raises(ValueError, match="ambigu"): + parse_permission('orders:create/approve|cancel') + +# ── Grant override ─────────────────────────────────────────────────── class TestGrantOverride: - """Test grant override functionality.""" + """Test grant override with named actions.""" def test_override_grant_sets_new_actions(self, test_user, editor_role, editor_role_grant, admin_user): - """Test overriding a grant with new actions.""" + """Test override_grant replaces actions and locks the grant.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Check initial state - grant_before = Grant.objects.get(user=test_user, scope='articles') - assert 'w' in grant_before.actions - - # Override with new actions (only 'r') - override_grant(test_user, 'articles', actions=['r']) - - # Grant should exist with only 'r' action - grant_after = Grant.objects.get(user=test_user, scope='articles') - assert grant_after.locked is True # Grant is now locked (custom) - assert 'r' in grant_after.actions - assert 'w' not in grant_after.actions + + grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role) + assert set(grant.actions) == {'read', 'write'} + + # Override to 'archive' only → should expand to archive/publish/read/write + override_grant(test_user, 'articles', ['archive']) + grant.refresh_from_db() + assert grant.locked is True + assert 'archive' in grant.actions + assert 'read' in grant.actions # implied def test_override_grant_with_empty_actions_deletes(self, test_user, editor_role, editor_role_grant, admin_user): - """Test overriding a grant with empty actions deletes it.""" + """Test override_grant with empty actions deletes the grant.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - override_grant(test_user, 'articles', actions=[]) - - assert not Grant.objects.filter(user=test_user, scope='articles').exists() + override_grant(test_user, 'articles', []) + assert Grant.objects.filter(user=test_user, scope='articles').count() == 0 def test_override_grant_not_found(self, test_user): - """Test overriding a non-existent grant raises exception.""" + """Test override_grant raises when grant not found.""" with pytest.raises(GrantNotFoundException): - override_grant(test_user, 'articles', actions=['r']) + override_grant(test_user, 'articles', ['read']) +# ── Group sync ─────────────────────────────────────────────────────── + class TestGroupSync: - """Test group synchronization.""" + """Test group sync with named actions.""" - def test_group_sync_updates_grants(self, test_user, staff_group, editor_role_grant, viewer_role_grant, admin_user): - """Test group sync updates grants after RoleGrant changes.""" + def test_group_sync_updates_grants(self, test_user, staff_group, editor_role, editor_role_grant, admin_user): + """Test group_sync updates grants after RoleGrant change.""" assign_group(test_user, 'staff', by=admin_user) - + # Modify role grant - editor_role_grant.actions = ['r', 'w', 'd'] + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - - # Sync group + stats = group_sync('staff') - - assert stats['users_synced'] == 1 - assert stats['grants_updated'] > 0 - + assert stats['users_synced'] >= 1 + # Check grant was updated - grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role_grant.role) - assert 'd' in grant.actions + grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role, user_group__isnull=False) + assert 'delete' in grant.actions - def test_group_sync_preserves_overrides(self, test_user, staff_group, editor_role_grant, editor_role, admin_user): - """Test group sync preserves custom overridden grants.""" - assign_group(test_user, 'staff', by=admin_user) - - # Verify grant exists before override - assert Grant.objects.filter(user=test_user, scope='articles').exists() - - # Override a grant with specific role - override_grant(test_user, 'articles', actions=['r'], role='editor') - - # Verify override worked - get the locked grant - grant_after_override = Grant.objects.get(user=test_user, scope='articles', role=editor_role, locked=True) - assert grant_after_override.locked is True # Locked grant - - # Sync group - stats = group_sync('staff') - - # Check override was preserved (locked grants should not be deleted) - grant_after_sync = Grant.objects.get(user=test_user, scope='articles', role=editor_role, locked=True) - assert grant_after_sync.locked is True # Still locked - assert 'r' in grant_after_sync.actions - assert 'w' not in grant_after_sync.actions - - def test_group_sync_with_role_filter(self, test_user, staff_group, editor_role_grant, viewer_role_grant, admin_user): - """Test group sync with role_slugs parameter to sync specific roles only.""" + def test_group_sync_preserves_overrides(self, test_user, staff_group, editor_role, editor_role_grant, admin_user): + """Test group_sync does not touch locked grants.""" assign_group(test_user, 'staff', by=admin_user) - - # Modify editor role grant - editor_role_grant.actions = ['r', 'w', 'd'] + + # Lock a grant + grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role) + grant.locked = True + grant.actions = ['read'] # custom + grant.save() + + # Modify role grant + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - - # Sync only editor role - stats = group_sync('staff', role_slugs=['editor']) - - assert stats['users_synced'] == 1 - assert stats['grants_updated'] > 0 - - # Check editor grant was updated - editor_grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role_grant.role) - assert 'd' in editor_grant.actions - - def test_group_sync_with_scope_filter(self, test_user, staff_group, editor_role_grant, admin_user): - """Test group sync with scope parameter for performance optimization.""" - # Create another role grant for different scope + + group_sync('staff') + + grant.refresh_from_db() + assert set(grant.actions) == {'read'} # unchanged + + def test_group_sync_with_scope_filter(self, test_user, staff_group, editor_role, editor_role_grant, admin_user): + """Test group_sync with scope parameter.""" + assign_group(test_user, 'staff', by=admin_user) + + # Create another grant for comments scope + viewer_role = Role.objects.get(slug='viewer') RoleGrant.objects.create( - role=editor_role_grant.role, + role=viewer_role, scope='comments', - actions=['r'], + actions=['read'], context={} ) - + # Re-assign group to pick up new grant + revoke_group(test_user, 'staff') assign_group(test_user, 'staff', by=admin_user) - - # Modify editor role grant for articles - editor_role_grant.actions = ['r', 'w', 'd'] + + # Modify editor grant + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - - # Sync only articles scope - stats = group_sync('staff', scope='articles') - - assert stats['users_synced'] == 1 - assert stats['grants_updated'] > 0 - - # Check articles grant was updated - articles_grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role_grant.role) - assert 'd' in articles_grant.actions - - def test_group_sync_with_role_and_scope_filter(self, test_user, staff_group, editor_role_grant, viewer_role_grant, admin_user): - """Test group sync with both role_slugs and scope parameters.""" + + # Sync only articles + group_sync('staff', scope='articles') + + # Articles grant updated + articles_grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role) + assert 'delete' in articles_grant.actions + + def test_group_sync_with_role_filter(self, test_user, staff_group, editor_role, editor_role_grant, admin_user): + """Test group_sync with role_slugs parameter.""" assign_group(test_user, 'staff', by=admin_user) - - # Modify editor role grant - editor_role_grant.actions = ['r', 'w', 'd'] + + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - - # Sync only editor role for articles scope - stats = group_sync('staff', role_slugs=['editor'], scope='articles') - - assert stats['users_synced'] == 1 - assert stats['grants_updated'] > 0 - - # Check editor grant was updated - editor_grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role_grant.role) - assert 'd' in editor_grant.actions + + stats = group_sync('staff', role_slugs=['editor']) + assert stats['users_synced'] >= 1 +# ── ScopePermission ────────────────────────────────────────────────── + class TestScopePermission: - """Test ScopePermission class.""" + """Test ScopePermission class with named actions.""" + + def test_scope_permission_and(self, test_user, editor_role, editor_role_grant, admin_user): + """Test ScopePermission with AND (/) separator.""" + assign_role(test_user, 'editor', 'articles', by=admin_user) + + perm = ScopePermission('articles:read/write') + request = Mock(user=test_user) + assert perm.has_permission(request, Mock()) is True - def test_scope_permission_basic(self, test_user, editor_role, editor_role_grant, admin_user): - """Test basic ScopePermission check.""" + perm2 = ScopePermission('articles:read/write/delete') + assert perm2.has_permission(request, Mock()) is False + + def test_scope_permission_or(self, test_user, editor_role, editor_role_grant, admin_user): + """Test ScopePermission with OR (|) separator.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - perm = ScopePermission('articles:r') - - request = Mock() - request.user = test_user - controller = Mock() - - assert perm.has_permission(request, controller) is True + + perm = ScopePermission('articles:read|delete') + request = Mock(user=test_user) + assert perm.has_permission(request, Mock()) is True # has read + + perm2 = ScopePermission('articles:delete|publish') + assert perm2.has_permission(request, Mock()) is False # has neither def test_scope_permission_with_context(self, test_user, editor_role, admin_user): """Test ScopePermission with context.""" RoleGrant.objects.create( role=editor_role, scope='articles', - actions=['r', 'w'], - context={'tenant_id': 123} + actions=['read', 'write'], + context={'tenant_id': 42} ) - assign_role(test_user, 'editor', 'articles', by=admin_user) - - perm = ScopePermission('articles:r', ctx={'tenant_id': 123}) - - request = Mock() - request.user = test_user - controller = Mock() - - assert perm.has_permission(request, controller) is True + perm = ScopePermission('articles:read') + request = Mock(user=test_user) + assert perm.has_permission(request, Mock()) is True + + perm_ctx = ScopePermission('articles:read?tenant_id=99') + assert perm_ctx.has_permission(request, Mock()) is False + + +# ── Access manager ─────────────────────────────────────────────────── class TestAccessManager: - """Test access_manager factory function.""" + """Test access_manager factory with named actions.""" - @override_settings( - ACCESS_MANAGER_SCOPE='access', - ACCESS_MANAGER_GROUP='manager', - ACCESS_MANAGER_ROLE='admin', - ACCESS_MANAGER_CONTEXT={} - ) - def test_access_manager_basic(self): - """Test access_manager creates correct permission using ROLE, not GROUP.""" - perm = access_manager('rw') - + def test_access_manager_basic(self, test_user, admin_user): + """Test access_manager creates correct ScopePermission.""" + perm = access_manager('read') assert isinstance(perm, ScopePermission) - assert perm.perm == 'access:rw:admin' + assert perm.perm == 'access:read:manager' - @override_settings( - ACCESS_MANAGER_SCOPE='access', - ACCESS_MANAGER_GROUP='manager', - ACCESS_MANAGER_ROLE=None, - ACCESS_MANAGER_CONTEXT={} - ) - def test_access_manager_without_role(self): - """Test access_manager without role produces scope:actions only.""" - perm = access_manager('r') - - assert perm.perm == 'access:r' + def test_access_manager_and(self): + """Test access_manager with AND actions.""" + perm = access_manager('read/write') + assert perm.perm == 'access:read/write:manager' - @override_settings( - ACCESS_MANAGER_SCOPE='access', - ACCESS_MANAGER_GROUP='manager', - ACCESS_MANAGER_ROLE='admin', - ACCESS_MANAGER_CONTEXT={'tenant_id': 123} - ) - def test_access_manager_with_context(self): - """Test access_manager with context.""" - perm = access_manager('rw') - - assert perm.perm == 'access:rw:admin' - assert perm.ctx == {'tenant_id': 123} - - def test_access_manager_missing_scope(self): - """Test access_manager raises error if scope not configured.""" - from django.conf import settings - - with override_settings(): - if hasattr(settings, 'ACCESS_MANAGER_SCOPE'): - delattr(settings, 'ACCESS_MANAGER_SCOPE') - - with pytest.raises(ImproperlyConfigured): - access_manager('r') - - -class TestCacheCheck: - """Test permission check caching.""" - - @override_settings(CACHE_CHECK_PERMISSION=False) - def test_cache_disabled(self, test_user, editor_role, editor_role_grant, admin_user): - """Test that cache is disabled when setting is False.""" - from oxutils.permissions.caches import cache_check - - assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Should work without cacheops - result = cache_check(test_user, 'articles', ['r']) - assert result is True - - @override_settings(CACHE_CHECK_PERMISSION=True) - def test_cache_enabled(self, test_user, editor_role, editor_role_grant, admin_user): - """Test that cache is enabled when setting is True.""" - from oxutils.permissions.caches import cache_check - - assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Should work with caching enabled - result = cache_check(test_user, 'articles', ['r']) - assert result is True + def test_access_manager_or(self): + """Test access_manager with OR actions.""" + perm = access_manager('read|write') + assert perm.perm == 'access:read|write:manager' + def test_access_manager_without_role(self, settings): + """Test access_manager when role is None.""" + settings.ACCESS_MANAGER_ROLE = None + perm = access_manager('read') + assert perm.perm == 'access:read' -class TestModels: - """Test permission models.""" - - def test_role_creation(self, db_setup): - """Test creating a role.""" - role = Role.objects.create(slug='test-role', name='Test Role') - - assert role.slug == 'test-role' - assert role.name == 'Test Role' - assert str(role) == 'test-role' # __str__ returns slug + def test_access_manager_with_context(self, settings): + """Test access_manager with context.""" + settings.ACCESS_MANAGER_CONTEXT = {'app': 'crm'} + perm = access_manager('read') + assert perm.ctx == {'app': 'crm'} - def test_group_creation(self, db_setup, editor_role): - """Test creating a group.""" - group = Group.objects.create(slug='test-group', name='Test Group') - group.roles.add(editor_role) - - assert group.slug == 'test-group' - assert group.name == 'Test Group' - assert editor_role in group.roles.all() - - def test_role_grant_unique_constraint(self, db_setup, editor_role): - """Test RoleGrant unique constraint.""" - rg1 = RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r', 'w'], - ) - - # Creating another with same role, scope, group should violate constraint - # But Django may allow it if the constraint is not properly enforced - # Let's just verify the first one was created - assert RoleGrant.objects.filter( - role=editor_role, - scope='articles', - ).count() == 1 + def test_access_manager_missing_scope(self, settings): + """Test access_manager uses default 'access' when scope is missing.""" + delattr(settings, 'ACCESS_MANAGER_SCOPE') + settings.ACCESS_MANAGER_ROLE = None + perm = access_manager('read') + assert perm.perm == 'access:read' # defaults to 'access', no role - def test_grant_unique_constraint(self, db_setup, test_user, editor_role): - """Test Grant unique constraint.""" - g1 = Grant.objects.create( - user=test_user, - scope='articles', - role=editor_role, - actions=['r', 'w'], - user_group=None - ) - - # The constraint is on (user, scope, role, user_group) - # Creating another with same values should be prevented - # But let's verify the first one was created - assert Grant.objects.filter( - user=test_user, - scope='articles', - user_group=None - ).count() == 1 +# ── Parse permission ───────────────────────────────────────────────── class TestParsePermission: - """Test parse_permission utility function.""" + """Test parse_permission with named actions.""" - def test_parse_simple_permission(self): - """Test parsing simple permission string.""" - scope, actions, role, context = parse_permission('articles:rw') - + def test_parse_single_action(self): + """Test parsing single action.""" + scope, actions, operator, role, context = parse_permission('articles:read') assert scope == 'articles' - assert actions == ['r', 'w'] + assert actions == ['read'] + assert operator == '&' assert role is None assert context == {} - def test_parse_permission_with_role(self): - """Test parsing permission with role.""" - scope, actions, role, context = parse_permission('articles:w:admin') - + def test_parse_and_actions(self): + """Test parsing AND (/).""" + scope, actions, operator, role, context = parse_permission('orders:create/approve/cancel') + assert scope == 'orders' + assert actions == ['create', 'approve', 'cancel'] + assert operator == '&' + assert role is None + + def test_parse_or_actions(self): + """Test parsing OR (|).""" + scope, actions, operator, role, context = parse_permission('orders:create|approve|cancel') + assert scope == 'orders' + assert actions == ['create', 'approve', 'cancel'] + assert operator == '|' + assert role is None + + def test_parse_with_role(self): + """Test parsing with role.""" + scope, actions, operator, role, context = parse_permission('articles:read/write:editor') assert scope == 'articles' - assert actions == ['w'] - assert role == 'admin' - assert context == {} + assert actions == ['read', 'write'] + assert operator == '&' + assert role == 'editor' - def test_parse_permission_with_context(self): - """Test parsing permission with query string context.""" - scope, actions, role, context = parse_permission('articles:rw?tenant_id=123&status=published') - + def test_parse_or_with_role(self): + """Test parsing OR with role.""" + scope, actions, operator, role, context = parse_permission('articles:read|write:editor') assert scope == 'articles' - assert actions == ['r', 'w'] + assert actions == ['read', 'write'] + assert operator == '|' + assert role == 'editor' + + def test_parse_with_context(self): + """Test parsing with context.""" + scope, actions, operator, role, context = parse_permission( + 'articles:read/write?tenant_id=42&status=active' + ) + assert scope == 'articles' + assert actions == ['read', 'write'] + assert operator == '&' assert role is None - assert context == {'tenant_id': 123, 'status': 'published'} + assert context == {'tenant_id': 42, 'status': 'active'} - def test_parse_permission_with_role_and_context(self): - """Test parsing permission with both role and context.""" - scope, actions, role, context = parse_permission('articles:w:editor?tenant_id=123') - + def test_parse_with_role_and_context(self): + """Test parsing with role and context.""" + scope, actions, operator, role, context = parse_permission( + 'articles:read/write:editor?tenant_id=42' + ) assert scope == 'articles' - assert actions == ['w'] + assert actions == ['read', 'write'] + assert operator == '&' assert role == 'editor' - assert context == {'tenant_id': 123} + assert context == {'tenant_id': 42} - def test_parse_permission_invalid_format(self): - """Test parsing invalid permission format raises error.""" + def test_parse_invalid_format(self): + """Test invalid format raises ValueError.""" with pytest.raises(ValueError, match="Format de permission invalide"): parse_permission('invalid') + def test_parse_mixed_separators_raises(self): + """Test mixed / and | raises.""" + with pytest.raises(ValueError, match="ambigu"): + parse_permission('orders:create/approve|cancel') + + +# ── Any action check ───────────────────────────────────────────────── class TestAnyActionCheck: - """Test any_action_check function.""" + """Test any_action_check with named actions.""" - def test_any_action_check_basic(self, test_user, editor_role, admin_user): - """Test any_action_check with basic usage.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r'], # Only read permission - ) - + def test_any_action_check_basic(self, test_user, editor_role, editor_role_grant, admin_user): + """Test OR check on same scope.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - # User has 'r', checking for ['r', 'w', 'd'] should return True (has at least 'r') - assert any_action_check(test_user, 'articles', ['r', 'w', 'd']) is True - - # User doesn't have 'w' or 'd', but has 'r', so should still be True - assert any_action_check(test_user, 'articles', ['w', 'd']) is False - - def test_any_action_check_with_multiple_actions(self, test_user, editor_role, admin_user): - """Test any_action_check when user has multiple actions.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r', 'w'], - ) - + # User has read + write + assert any_action_check(test_user, 'articles', ['read']) is True + assert any_action_check(test_user, 'articles', ['read', 'delete']) is True # has read + assert any_action_check(test_user, 'articles', ['delete', 'publish']) is False # has neither + + def test_any_action_check_with_role(self, test_user, editor_role, editor_role_grant, admin_user): + """Test OR check filtered by role.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - # User has ['r', 'w'], checking for any of ['r', 'w', 'd'] should be True - assert any_action_check(test_user, 'articles', ['r', 'w', 'd']) is True - - # User has 'w', checking for ['w', 'd'] should be True - assert any_action_check(test_user, 'articles', ['w', 'd']) is True - - # User doesn't have 'd' or 'x', should be False - assert any_action_check(test_user, 'articles', ['d', 'x']) is False - - def test_any_action_check_with_role(self, test_user, editor_role, admin_user): - """Test any_action_check with role filter.""" + assert any_action_check(test_user, 'articles', ['read', 'delete'], role='editor') is True + assert any_action_check(test_user, 'articles', ['read', 'delete'], role='viewer') is False + + def test_any_action_check_with_context(self, test_user, editor_role, admin_user): + """Test OR check with context.""" RoleGrant.objects.create( role=editor_role, scope='articles', - actions=['r', 'w'], + actions=['read', 'write'], + context={'tenant_id': 42} ) - assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Check with role filter - assert any_action_check(test_user, 'articles', ['r', 'w'], role='editor') is True - assert any_action_check(test_user, 'articles', ['d'], role='editor') is False - assert any_action_check(test_user, 'articles', ['r'], role='nonexistent') is False - - def test_any_action_check_with_context(self, test_user, editor_role): - """Test any_action_check with context.""" - Grant.objects.create( - user=test_user, - scope='articles', - role=editor_role, - actions=['r', 'w'], - context={'tenant_id': 123} - ) - - # With matching context - assert any_action_check(test_user, 'articles', ['r', 'w'], tenant_id=123) is True - - # With non-matching context - assert any_action_check(test_user, 'articles', ['r', 'w'], tenant_id=456) is False + assert any_action_check(test_user, 'articles', ['read'], tenant_id=42) is True + assert any_action_check(test_user, 'articles', ['read'], tenant_id=99) is False + +# ── Any permission check ───────────────────────────────────────────── class TestAnyPermissionCheck: - """Test any_permission_check function.""" + """Test any_permission_check with named actions.""" - def test_any_permission_check_basic(self, test_user, editor_role, admin_user): - """Test any_permission_check with basic usage.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r'], - ) - + def test_any_permission_check_basic(self, test_user, editor_role, editor_role_grant, admin_user): + """Test OR across different permission strings.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - # User has 'articles:r', checking for ['articles:r', 'invoices:w'] should be True - assert any_permission_check(test_user, 'articles:r', 'invoices:w') is True - - # User doesn't have any of these - assert any_permission_check(test_user, 'invoices:w', 'users:d') is False - - def test_any_permission_check_multiple_scopes(self, test_user, editor_role, admin_user): - """Test any_permission_check with multiple scopes.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r', 'w'], - ) - RoleGrant.objects.create( - role=editor_role, - scope='invoices', - actions=['r'], - ) - + assert any_permission_check(test_user, 'articles:read') is True + assert any_permission_check(test_user, 'articles:delete', 'articles:read') is True # has read + assert any_permission_check(test_user, 'articles:delete', 'articles:publish') is False + + def test_any_permission_check_mixed_operators(self, test_user, editor_role, editor_role_grant, admin_user): + """Test AND within one perm, OR across perms.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - # User has both permissions - assert any_permission_check(test_user, 'articles:r', 'invoices:r') is True - - # User has at least one (articles:w) - assert any_permission_check(test_user, 'articles:w', 'users:d') is True - - # User has none of these - assert any_permission_check(test_user, 'users:r', 'reports:w') is False - - def test_any_permission_check_with_roles(self, test_user, editor_role, admin_user): - """Test any_permission_check with role filters.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r', 'w'], - ) - + # articles:read/write = AND (must have both) → True + # articles:delete/publish = AND (must have both) → False + # overall OR → True + assert any_permission_check(test_user, 'articles:read/write', 'articles:delete/publish') is True + + def test_any_permission_check_with_roles(self, test_user, editor_role, viewer_role, editor_role_grant, admin_user): + """Test OR check with role filters.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Check with role in permission string assert any_permission_check( test_user, - 'articles:r:editor', - 'invoices:w:admin' + 'articles:read:editor', + 'articles:read:viewer' ) is True - - # No match with wrong role - assert any_permission_check( - test_user, - 'articles:r:nonexistent', - 'invoices:w:admin' - ) is False - def test_any_permission_check_with_context(self, test_user, editor_role): - """Test any_permission_check with context in permission strings.""" - Grant.objects.create( - user=test_user, - scope='articles', - role=editor_role, - actions=['r'], - context={'tenant_id': 123} - ) - Grant.objects.create( - user=test_user, - scope='invoices', + def test_any_permission_check_with_context(self, test_user, editor_role, admin_user): + """Test OR check with context.""" + RoleGrant.objects.create( role=editor_role, - actions=['w'], - context={'tenant_id': 456} + scope='articles', + actions=['read', 'write'], + context={'tenant_id': 42} ) - - # User has articles:r with tenant_id=123 - assert any_permission_check( - test_user, - 'articles:r?tenant_id=123', - 'users:d' - ) is True - - # User has invoices:w with tenant_id=456 + assign_role(test_user, 'editor', 'articles', by=admin_user) assert any_permission_check( test_user, - 'articles:r?tenant_id=999', - 'invoices:w?tenant_id=456' - ) is True + 'articles:read?tenant_id=42', + 'articles:read?tenant_id=99' + ) is True # first matches - def test_any_permission_check_empty_permissions(self, test_user): - """Test any_permission_check with no permissions returns False.""" + def test_any_permission_check_empty(self, test_user): + """Test empty perms returns False.""" assert any_permission_check(test_user) is False +# ── ScopeAnyActionPermission ───────────────────────────────────────── + class TestScopeAnyActionPermission: - """Test ScopeAnyActionPermission class.""" + """Test ScopeAnyActionPermission (always OR).""" - def test_scope_any_action_permission_basic(self, test_user, editor_role, admin_user): - """Test ScopeAnyActionPermission basic functionality.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r'], - ) - + def test_scope_any_action_basic(self, test_user, editor_role, editor_role_grant, admin_user): + """Test always-OR on same scope.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Create mock request - request = Mock() - request.user = test_user - - # User has 'r', permission checks for 'rwd' (any of them) - permission = ScopeAnyActionPermission('articles:rwd') - assert permission.has_permission(request, None) is True - - # User doesn't have 'w' or 'd' - permission = ScopeAnyActionPermission('articles:wd') - assert permission.has_permission(request, None) is False - - def test_scope_any_action_permission_with_role(self, test_user, editor_role, admin_user): - """Test ScopeAnyActionPermission with role.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r', 'w'], - ) - + perm = ScopeAnyActionPermission('articles:read/delete') + request = Mock(user=test_user) + # Even with '/', this class forces OR → True (has read) + assert perm.has_permission(request, Mock()) is True + + def test_scope_any_action_fails(self, test_user, editor_role, editor_role_grant, admin_user): + """Test always-OR fails when user has none.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - request = Mock() - request.user = test_user - - permission = ScopeAnyActionPermission('articles:rwd:editor') - assert permission.has_permission(request, None) is True - - permission = ScopeAnyActionPermission('articles:rwd:nonexistent') - assert permission.has_permission(request, None) is False - - def test_scope_any_action_permission_with_context(self, test_user, editor_role): - """Test ScopeAnyActionPermission with context.""" - Grant.objects.create( - user=test_user, - scope='articles', - role=editor_role, - actions=['r', 'w'], - context={'tenant_id': 123} - ) - - request = Mock() - request.user = test_user - - permission = ScopeAnyActionPermission('articles:rwd?tenant_id=123') - assert permission.has_permission(request, None) is True - - permission = ScopeAnyActionPermission('articles:rwd?tenant_id=456') - assert permission.has_permission(request, None) is False - - def test_scope_any_action_permission_validation(self): - """Test ScopeAnyActionPermission validation.""" - with pytest.raises(ValueError, match="Permission string must be provided"): + perm = ScopeAnyActionPermission('articles:delete/publish') + request = Mock(user=test_user) + assert perm.has_permission(request, Mock()) is False + + def test_scope_any_action_with_role(self, test_user, editor_role, editor_role_grant, admin_user): + """Test always-OR with role filter.""" + assign_role(test_user, 'editor', 'articles', by=admin_user) + perm = ScopeAnyActionPermission('articles:read/delete:editor') + request = Mock(user=test_user) + assert perm.has_permission(request, Mock()) is True + + def test_scope_any_action_validation(self): + """Test validation on empty string.""" + with pytest.raises(ValueError): ScopeAnyActionPermission('') +# ── ScopeAnyPermission ─────────────────────────────────────────────── + class TestScopeAnyPermission: - """Test ScopeAnyPermission class.""" + """Test ScopeAnyPermission with named actions.""" - def test_scope_any_permission_basic(self, test_user, editor_role, admin_user): - """Test ScopeAnyPermission basic functionality.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r'], - ) - + def test_scope_any_permission_basic(self, test_user, editor_role, editor_role_grant, admin_user): + """Test OR across multiple permission strings.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - request = Mock() - request.user = test_user - - # User has 'articles:r', checking for ['articles:r', 'invoices:w'] - permission = ScopeAnyPermission('articles:r', 'invoices:w') - assert permission.has_permission(request, None) is True - - # User doesn't have any of these - permission = ScopeAnyPermission('invoices:w', 'users:d') - assert permission.has_permission(request, None) is False - - def test_scope_any_permission_multiple_scopes(self, test_user, editor_role, admin_user): - """Test ScopeAnyPermission with multiple scopes.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r', 'w'], - ) - RoleGrant.objects.create( - role=editor_role, - scope='invoices', - actions=['r'], - ) - + perm = ScopeAnyPermission('articles:delete', 'articles:read') + request = Mock(user=test_user) + assert perm.has_permission(request, Mock()) is True + + def test_scope_any_permission_fails(self, test_user, editor_role, editor_role_grant, admin_user): + """Test fails when none match.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - request = Mock() - request.user = test_user - - # User has at least one of these - permission = ScopeAnyPermission('articles:w', 'users:d', 'reports:r') - assert permission.has_permission(request, None) is True - - def test_scope_any_permission_with_roles(self, test_user, editor_role, admin_user): - """Test ScopeAnyPermission with role filters.""" - RoleGrant.objects.create( - role=editor_role, - scope='articles', - actions=['r', 'w'], - ) - + perm = ScopeAnyPermission('articles:delete', 'articles:publish') + request = Mock(user=test_user) + assert perm.has_permission(request, Mock()) is False + + def test_scope_any_permission_with_roles(self, test_user, editor_role, editor_role_grant, admin_user): + """Test with role filters.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - - request = Mock() - request.user = test_user - - permission = ScopeAnyPermission('articles:r:editor', 'invoices:w:admin') - assert permission.has_permission(request, None) is True - - permission = ScopeAnyPermission('articles:r:nonexistent', 'invoices:w:admin') - assert permission.has_permission(request, None) is False - - def test_scope_any_permission_with_context(self, test_user, editor_role): - """Test ScopeAnyPermission with context.""" - Grant.objects.create( - user=test_user, - scope='articles', - role=editor_role, - actions=['r'], - context={'tenant_id': 123} - ) - Grant.objects.create( - user=test_user, - scope='invoices', - role=editor_role, - actions=['w'], - context={'tenant_id': 456} - ) - - request = Mock() - request.user = test_user - - permission = ScopeAnyPermission( - 'articles:r?tenant_id=123', - 'invoices:w?tenant_id=456' + perm = ScopeAnyPermission( + 'articles:read:editor', + 'articles:write:viewer' ) - assert permission.has_permission(request, None) is True + request = Mock(user=test_user) + assert perm.has_permission(request, Mock()) is True def test_scope_any_permission_validation(self): - """Test ScopeAnyPermission validation.""" - with pytest.raises(ValueError, match="At least one permission string must be provided"): + """Test validation on empty args.""" + with pytest.raises(ValueError): ScopeAnyPermission() +# ── Activate / Deactivate ──────────────────────────────────────────── + class TestActivateDeactivatePermissions: - """Test activate_user_permissions and deactivate_user_permissions.""" + """Test activate/deactivate with named actions.""" def test_activate_all_user_grants(self, test_user, editor_role, editor_role_grant, admin_user): - """Activate all grants for a user.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - # Désactiver d'abord - deactivate_user_permissions(test_user) - assert not check(test_user, 'articles', ['r']) - - # Activer + # Deactivate first + Grant.objects.filter(user=test_user).update(is_active=False) activate_user_permissions(test_user) - assert check(test_user, 'articles', ['r']) + assert Grant.objects.filter(user=test_user, is_active=True).exists() def test_deactivate_all_user_grants(self, test_user, editor_role, editor_role_grant, admin_user): - """Deactivate all grants for a user — check() must return False.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - assert check(test_user, 'articles', ['r']) - deactivate_user_permissions(test_user) - assert not check(test_user, 'articles', ['r']) + assert not Grant.objects.filter(user=test_user, is_active=True).exists() - def test_activate_by_scope(self, test_user, editor_role, editor_role_grant, viewer_role, viewer_role_grant, admin_user): - """Activate only grants for a specific scope.""" + def test_activate_by_scope(self, test_user, editor_role, editor_role_grant, admin_user): assign_role(test_user, 'editor', 'articles', by=admin_user) - assign_role(test_user, 'viewer', 'articles', by=admin_user) - deactivate_user_permissions(test_user) - - # Activer seulement le scope 'articles' + Grant.objects.filter(user=test_user).update(is_active=False) activate_user_permissions(test_user, scope='articles') - assert check(test_user, 'articles', ['r']) + assert Grant.objects.filter(user=test_user, scope='articles', is_active=True).exists() - def test_deactivate_by_scope(self, test_user, editor_role, editor_role_grant, viewer_role, viewer_role_grant, admin_user): - """Deactivate only grants for a specific scope.""" - # Le viewer_role_grant n'existe pas pour 'invoices', créons un grant manuel + def test_deactivate_by_scope(self, test_user, editor_role, editor_role_grant, admin_user): assign_role(test_user, 'editor', 'articles', by=admin_user) - assign_role(test_user, 'viewer', 'articles', by=admin_user) - deactivate_user_permissions(test_user, scope='articles') - assert not check(test_user, 'articles', ['r']) - - def test_activate_by_app(self, test_user, editor_role, editor_role_grant, admin_user): - """Activate only grants whose role belongs to a given app.""" - editor_role.app = 'blog' - editor_role.save() - assign_role(test_user, 'editor', 'articles', by=admin_user) - deactivate_user_permissions(test_user) - - activate_user_permissions(test_user, app='blog') - assert check(test_user, 'articles', ['r']) - - def test_deactivate_by_app(self, test_user, editor_role, editor_role_grant, admin_user): - """Deactivate only grants whose role belongs to a given app.""" - editor_role.app = 'cms' - editor_role.save() - assign_role(test_user, 'editor', 'articles', by=admin_user) - assert check(test_user, 'articles', ['r']) - - deactivate_user_permissions(test_user, app='cms') - assert not check(test_user, 'articles', ['r']) - - def test_activate_no_matching_grants_is_passive(self, test_user): - """activate_user_permissions on a user with no grants does not raise.""" - activate_user_permissions(test_user) - activate_user_permissions(test_user, scope='nonexistent') - activate_user_permissions(test_user, app='noapp') - - def test_deactivate_no_matching_grants_is_passive(self, test_user): - """deactivate_user_permissions on a user with no grants does not raise.""" - deactivate_user_permissions(test_user) - deactivate_user_permissions(test_user, scope='nonexistent') - deactivate_user_permissions(test_user, app='noapp') + assert not Grant.objects.filter(user=test_user, scope='articles', is_active=True).exists() def test_reactivate_restores_check(self, test_user, editor_role, editor_role_grant, admin_user): - """Deactivate then reactivate — check() passes again.""" + """Test that reactivating grants restores permission checks.""" assign_role(test_user, 'editor', 'articles', by=admin_user) - assert check(test_user, 'articles', ['r']) - + assert check(test_user, 'articles', ['read']) is True deactivate_user_permissions(test_user) - assert not check(test_user, 'articles', ['r']) - + assert check(test_user, 'articles', ['read']) is False activate_user_permissions(test_user) - assert check(test_user, 'articles', ['r']) + assert check(test_user, 'articles', ['read']) is True + +# ── Extra permissions ──────────────────────────────────────────────── class TestExtraPermissions: - """Tests for extra_permissions() utility.""" + """Test extra_permissions with named actions.""" - @override_settings() def test_returns_empty_list_when_not_configured(self): - """Returns [] when EXTRA_PERMISSIONS is not defined.""" - extra_permissions.cache_clear() - # Delete the setting manually since override_settings doesn't handle deletions - if hasattr(django_settings, 'EXTRA_PERMISSIONS'): - delattr(django_settings, 'EXTRA_PERMISSIONS') - result = extra_permissions() - assert result == [] + assert extra_permissions() == [] + + @override_settings(EXTRA_PERMISSIONS=[]) + def test_returns_empty_list_when_configured_empty(self): + assert extra_permissions() == [] @override_settings( - EXTRA_PERMISSIONS=[ - 'oxutils.permissions.perms.ScopePermission', - ] + EXTRA_PERMISSIONS=['oxutils.permissions.perms.ScopePermission'] ) - def test_imports_and_returns_instances(self): - """Dotted paths are imported and returned as-is.""" + def test_imports_and_returns_instances(self, settings): + # Clear cache extra_permissions.cache_clear() - result = extra_permissions() - assert len(result) == 1 - # import_string returns the object at the path (class or instance) - assert result[0] is ScopePermission + # import_string returns the class itself, not an instance + from ninja_extra.permissions import BasePermission + assert issubclass(result[0], BasePermission) - @override_settings( - EXTRA_PERMISSIONS=[ - 'nonexistent.module.Permission', - ] - ) - def test_raises_improperly_configured_on_bad_path(self): - """Invalid dotted path raises ImproperlyConfigured.""" + @override_settings(EXTRA_PERMISSIONS=['nonexistent.Path']) + def test_raises_on_bad_path(self): extra_permissions.cache_clear() - - with pytest.raises(ImproperlyConfigured, match='Cannot import'): + with pytest.raises(ImproperlyConfigured): extra_permissions() - @override_settings(EXTRA_PERMISSIONS='not_a_list') - def test_raises_on_non_list_setting(self): - """Non-list EXTRA_PERMISSIONS raises ImproperlyConfigured.""" + @override_settings(EXTRA_PERMISSIONS=42) + def test_raises_on_non_list(self): extra_permissions.cache_clear() - - with pytest.raises(ImproperlyConfigured, match='must be a list'): + with pytest.raises(ImproperlyConfigured): extra_permissions() - @override_settings( - EXTRA_PERMISSIONS=[ - 'oxutils.permissions.perms.ScopePermission', - 'oxutils.permissions.perms.ScopeAnyPermission', - ] - ) - def test_multiple_permissions(self): - """Multiple entries are all imported.""" - extra_permissions.cache_clear() - result = extra_permissions() +# ── Models ─────────────────────────────────────────────────────────── - assert len(result) == 2 - assert result[0] is ScopePermission - assert result[1] is ScopeAnyPermission +class TestModels: + """Test models with named actions.""" - def test_result_is_cached(self): - """Second call returns the same list (lru_cache).""" - extra_permissions.cache_clear() + def test_role_creation(self, db_setup): + role = Role.objects.create(slug='test_role', name='Test Role') + assert str(role) == 'test_role' - with override_settings( - EXTRA_PERMISSIONS=['oxutils.permissions.perms.ScopePermission'] - ): - result1 = extra_permissions() - result2 = extra_permissions() + def test_group_creation(self, db_setup, editor_role): + group = Group.objects.create(slug='test_group', name='Test Group') + group.roles.add(editor_role) + # save() calls slugify(self.name) → 'test-group' + assert str(group) == 'test-group' - assert result1 is result2 + def test_role_grant_clean_expands(self, db_setup, editor_role): + """Test RoleGrant.clean() expands actions based on hierarchy.""" + rg = RoleGrant.objects.create( + role=editor_role, + scope='articles', + actions=['write'], # write implies read + context={} + ) + assert set(rg.actions) == {'read', 'write'} + def test_role_grant_expand_archive(self, db_setup, editor_role): + """Test deep expansion.""" + rg = RoleGrant.objects.create( + role=editor_role, + scope='articles', + actions=['archive'], # archive → publish → write → read + context={} + ) + assert 'archive' in rg.actions + assert 'publish' in rg.actions + assert 'write' in rg.actions + assert 'read' in rg.actions + + def test_grant_unique_constraint(self, test_user, editor_role, db_setup): + """Test unique constraint on grants.""" + rg = RoleGrant.objects.create( + role=editor_role, + scope='articles', + actions=['read'], + context={} + ) + Grant.objects.create( + user=test_user, + scope='articles', + role=editor_role, + actions=['read'], + ) + with pytest.raises(Exception): + Grant.objects.create( + user=test_user, + scope='articles', + role=editor_role, + actions=['read'], + ) -# ── Helpers for preset discovery tests ──────────────────────────── -def _fake_app_config(name, label=None): - """Return a mock AppConfig with *name* and *label*.""" - cfg = Mock() - cfg.name = name - cfg.label = label or name - return cfg +# ── Preset discovery helpers ───────────────────────────────────────── +def _fake_app_config(label, module_attrs): + """Return an object that looks like a Django AppConfig.""" + return type("FakeConfig", (), {"label": label, "name": f"fake_{label}"}) -def _patch_discovery(app_configs, module_attrs=None): - """ - Context manager that patches ``apps.get_app_configs`` and - ``importlib.import_module`` for discovery tests. - *app_configs*: list of mock AppConfigs. - *module_attrs*: dict mapping ``app_config.name`` → dict of module attributes. - """ - from contextlib import ExitStack +def _patch_get_app_configs(monkeypatch, configs): + """Mock `apps.get_app_configs` to return *configs*.""" + monkeypatch.setattr("django.apps.apps.get_app_configs", lambda: configs) - module_attrs = module_attrs or {} - def _import_module(name): - for cfg in app_configs: - if name == f"{cfg.name}.permissions": +def _patch_import_module(monkeypatch, module_map): + """ + Monkeypatch `importlib.import_module` so that for each app label + we return a fake module with the given attributes. + """ + import importlib + orig = importlib.import_module + + def _import(modname): + for label, attrs in module_map.items(): + if modname == f"fake_{label}.permissions": mod = Mock() - for attr, val in (module_attrs.get(cfg.name, {})).items(): - setattr(mod, attr, val) + for k, v in attrs.items(): + setattr(mod, k, v) return mod - raise ModuleNotFoundError(f"No module named '{name}'") - - stack = ExitStack() - stack.enter_context( - patch.object(presets_mod.apps, "get_app_configs", return_value=app_configs) - ) - stack.enter_context( - patch.object(presets_mod.importlib, "import_module", side_effect=_import_module) - ) - return stack + return orig(modname) + monkeypatch.setattr(importlib, "import_module", _import) -# ── Discovery tests ─────────────────────────────────────────────── class TestDiscoverAppPresets: - """Tests for discover_app_presets().""" - - def test_discovers_preset_from_app(self): - """App exporting PERMISSION_PRESET is discovered.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], - { - "blog": { - "PERMISSION_PRESET": { - "roles": [{"slug": "author"}], - "groups": [{"slug": "writers"}], - "role_grants": [{"role": "author", "scope": "posts", "actions": ["r", "w"]}], - } - } - }, - ): - result = presets_mod.discover_app_presets() - - assert len(result) == 1 - preset = result[0] - assert preset["roles"][0]["app"] == "blog" - assert preset["groups"][0]["app"] == "blog" - assert preset["role_grants"][0]["app"] == "blog" - - def test_app_without_permissions_module_is_skipped(self): - """App without a permissions.py is silently skipped.""" - cfg = _fake_app_config("no_perms") - with _patch_discovery([cfg]): - result = presets_mod.discover_app_presets() - assert result == [] - - def test_app_without_preset_is_skipped(self): - """App whose permissions.py has no PERMISSION_PRESET is skipped.""" - cfg = _fake_app_config("plain") - with _patch_discovery([cfg], {"plain": {"SOME_OTHER_VAR": True}}): - result = presets_mod.discover_app_presets() - assert result == [] - - def test_app_with_non_dict_preset_is_skipped(self): - """String / list PERMISSION_PRESET is ignored.""" - cfg = _fake_app_config("bad") - with _patch_discovery([cfg], {"bad": {"PERMISSION_PRESET": "not_a_dict"}}): - result = presets_mod.discover_app_presets() - assert result == [] - - def test_multiple_apps_are_all_discovered(self): - """Each app contributes its own preset dict.""" - blog = _fake_app_config("blog") - shop = _fake_app_config("shop") - with _patch_discovery( - [blog, shop], - { - "blog": {"PERMISSION_PRESET": {"roles": [{"slug": "author"}]}}, - "shop": {"PERMISSION_PRESET": {"roles": [{"slug": "seller"}]}}, - }, - ): - result = presets_mod.discover_app_presets() - - assert len(result) == 2 - - def test_app_label_is_set_on_all_entities(self): - """Roles, groups, and role_grants all get 'app' set to the app label.""" - cfg = _fake_app_config("cms", label="my_cms") - with _patch_discovery( - [cfg], - { - "cms": { - "PERMISSION_PRESET": { - "roles": [{"slug": "editor"}], - "groups": [{"slug": "editors"}], - "role_grants": [{"role": "editor", "scope": "pages", "actions": ["r"]}], - } + """Test discover_app_presets with named actions.""" + + def test_discovers_preset_from_app(self, monkeypatch): + config = _fake_app_config("myapp", {}) + _patch_get_app_configs(monkeypatch, [config]) + _patch_import_module(monkeypatch, { + "myapp": { + "PERMISSION_PRESET": { + "roles": [{"slug": "editor", "name": "Editor"}], + "groups": [], + "role_grants": [], } - }, - ): - result = presets_mod.discover_app_presets() - - preset = result[0] - assert preset["roles"][0]["app"] == "my_cms" - assert preset["groups"][0]["app"] == "my_cms" - assert preset["role_grants"][0]["app"] == "my_cms" - - def test_preserves_existing_app_value(self): - """If 'app' is already set on an entity, it is not overwritten.""" - cfg = _fake_app_config("blog", label="blog") - with _patch_discovery( - [cfg], - { - "blog": { - "PERMISSION_PRESET": { - "roles": [{"slug": "author", "app": "custom_app"}], - } + } + }) + presets = presets_mod.discover_app_presets() + assert len(presets) >= 1 + assert presets[0]["roles"][0]["slug"] == "editor" + + def test_preset_includes_actions(self, monkeypatch): + """Test that actions are discovered.""" + config = _fake_app_config("myapp", {}) + _patch_get_app_configs(monkeypatch, [config]) + _patch_import_module(monkeypatch, { + "myapp": { + "PERMISSION_PRESET": { + "actions": { + "orders": { + "create": {"implies": []}, + "ship": {"implies": ["create"]}, + } + }, + "roles": [], + "groups": [], + "role_grants": [], } - }, - ): - result = presets_mod.discover_app_presets() + } + }) + presets = presets_mod.discover_app_presets() + assert "actions" in presets[0] + assert "orders" in presets[0]["actions"] - # setdefault should not overwrite an existing value - assert result[0]["roles"][0]["app"] == "custom_app" + def test_app_without_permissions_module_is_skipped(self, monkeypatch): + config = _fake_app_config("noapp", {}) + _patch_get_app_configs(monkeypatch, [config]) + import importlib + orig = importlib.import_module -class TestDiscoverAccessScopes: - """Tests for discover_access_scopes().""" - - def test_discovers_scopes_from_app(self): - """App exporting ACCESS_SCOPES is discovered.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], {"blog": {"ACCESS_SCOPES": ["posts", "comments"]}} - ): - result = presets_mod.discover_access_scopes() - - assert result == ["posts", "comments"] - - def test_app_without_scopes_is_skipped(self): - """App without ACCESS_SCOPES is silently skipped.""" - cfg = _fake_app_config("plain") - with _patch_discovery([cfg], {"plain": {}}): - result = presets_mod.discover_access_scopes() - assert result == [] - - def test_app_with_non_list_scopes_is_skipped(self): - """String / dict ACCESS_SCOPES is ignored.""" - cfg = _fake_app_config("bad") - with _patch_discovery([cfg], {"bad": {"ACCESS_SCOPES": "not_a_list"}}): - result = presets_mod.discover_access_scopes() - assert result == [] - - def test_deduplicates_across_apps(self): - """Same scope from multiple apps appears only once.""" - blog = _fake_app_config("blog") - shop = _fake_app_config("shop") - with _patch_discovery( - [blog, shop], - { - "blog": {"ACCESS_SCOPES": ["posts", "common"]}, - "shop": {"ACCESS_SCOPES": ["products", "common"]}, - }, - ): - result = presets_mod.discover_access_scopes() - - assert result == ["posts", "common", "products"] - - def test_multiple_apps_are_all_discovered(self): - """All apps contribute their scopes in order.""" - blog = _fake_app_config("blog") - shop = _fake_app_config("shop") - with _patch_discovery( - [blog, shop], - { - "blog": {"ACCESS_SCOPES": ["posts"]}, - "shop": {"ACCESS_SCOPES": ["products"]}, - }, - ): - result = presets_mod.discover_access_scopes() - - assert "posts" in result - assert "products" in result - - -class TestDiscoverAccessApplications: - """Tests for discover_access_applications().""" - - def test_discovers_application_name_from_app(self): - """App exporting ACCESS_APPLICATION_NAME is discovered.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], {"blog": {"ACCESS_APPLICATION_NAME": "blog_app"}} - ): - result = presets_mod.discover_access_applications() - - assert result == ["blog_app"] - - def test_app_without_name_is_skipped(self): - """App without ACCESS_APPLICATION_NAME is skipped.""" - cfg = _fake_app_config("plain") - with _patch_discovery([cfg], {"plain": {}}): - result = presets_mod.discover_access_applications() - assert result == [] - - def test_app_with_non_string_name_is_skipped(self): - """Non-string ACCESS_APPLICATION_NAME is ignored.""" - cfg = _fake_app_config("bad") - with _patch_discovery( - [cfg], {"bad": {"ACCESS_APPLICATION_NAME": 123}} - ): - result = presets_mod.discover_access_applications() - assert result == [] - - def test_deduplicates_across_apps(self): - """Same application name from multiple apps appears only once.""" - a = _fake_app_config("app_a") - b = _fake_app_config("app_b") - with _patch_discovery( - [a, b], - { - "app_a": {"ACCESS_APPLICATION_NAME": "crm"}, - "app_b": {"ACCESS_APPLICATION_NAME": "crm"}, - }, - ): - result = presets_mod.discover_access_applications() + def _import(modname): + if modname == "fake_noapp.permissions": + raise ModuleNotFoundError() + return orig(modname) - assert result == ["crm"] + monkeypatch.setattr(importlib, "import_module", _import) + presets = presets_mod.discover_app_presets() + assert presets == [] + def test_app_without_preset_is_skipped(self, monkeypatch): + config = _fake_app_config("noapp", {}) + _patch_get_app_configs(monkeypatch, [config]) + _patch_import_module(monkeypatch, {"noapp": {}}) + presets = presets_mod.discover_app_presets() + assert presets == [] + + +class TestDiscoverAccessScopes: + """Test discover_access_scopes.""" + + def test_discovers_scopes_from_app(self, monkeypatch): + config = _fake_app_config("myapp", {}) + _patch_get_app_configs(monkeypatch, [config]) + _patch_import_module(monkeypatch, { + "myapp": {"ACCESS_SCOPES": ["orders", "invoices"]} + }) + scopes = presets_mod.discover_access_scopes() + keys = {s["key"] for s in scopes} + assert "orders" in keys + assert "invoices" in keys + + def test_discovers_scopes_with_labels(self, monkeypatch): + """ACCESS_SCOPES can be a list of dicts with key and label.""" + config = _fake_app_config("myapp", {}) + _patch_get_app_configs(monkeypatch, [config]) + _patch_import_module(monkeypatch, { + "myapp": {"ACCESS_SCOPES": [ + {"key": "orders", "label": "Orders"}, + ]} + }) + scopes = presets_mod.discover_access_scopes() + assert len(scopes) == 1 + assert scopes[0]["key"] == "orders" + assert scopes[0]["label"] == "Orders" + + def test_deduplicates(self, monkeypatch): + c1 = _fake_app_config("a", {}) + c2 = _fake_app_config("b", {}) + _patch_get_app_configs(monkeypatch, [c1, c2]) + _patch_import_module(monkeypatch, { + "a": {"ACCESS_SCOPES": ["orders"]}, + "b": {"ACCESS_SCOPES": ["orders"]}, + }) + scopes = presets_mod.discover_access_scopes() + keys = [s["key"] for s in scopes] + assert keys.count("orders") == 1 -# ── Registration tests ──────────────────────────────────────────── class TestRegisterPreset: - """Tests for register_preset().""" - - def test_extends_base_with_discovered(self): - """Base preset is extended with discovered entries.""" - cfg = _fake_app_config("blog") - base = {"roles": [{"slug": "admin"}], "groups": [], "role_grants": []} - - with _patch_discovery( - [cfg], - { - "blog": { - "PERMISSION_PRESET": { - "roles": [{"slug": "author"}], - "groups": [{"slug": "writers"}], - "role_grants": [{"role": "author", "scope": "posts", "actions": ["r"]}], - } + """Test register_preset with actions.""" + + def test_extends_base_with_discovered(self, monkeypatch): + config = _fake_app_config("myapp", {}) + _patch_get_app_configs(monkeypatch, [config]) + _patch_import_module(monkeypatch, { + "myapp": { + "PERMISSION_PRESET": { + "actions": { + "orders": { + "create": {"implies": []}, + } + }, + "roles": [{"slug": "editor", "name": "Editor"}], + "groups": [], + "role_grants": [], + } + } + }) + base = {"roles": [], "groups": [], "role_grants": [], "actions": {}} + result = presets_mod.register_preset(base) + assert len(result["roles"]) >= 1 + assert "orders" in result["actions"] + + def test_different_scopes_from_multiple_apps_ok(self, monkeypatch): + """Different scopes from different apps are fine.""" + c1 = _fake_app_config("a", {}) + c2 = _fake_app_config("b", {}) + _patch_get_app_configs(monkeypatch, [c1, c2]) + _patch_import_module(monkeypatch, { + "a": { + "PERMISSION_PRESET": { + "actions": {"scope_a": {"read": {"implies": []}}}, + "roles": [], "groups": [], "role_grants": [], + } + }, + "b": { + "PERMISSION_PRESET": { + "actions": {"scope_b": {"read": {"implies": []}}}, + "roles": [], "groups": [], "role_grants": [], + } + }, + }) + base = {"roles": [], "groups": [], "role_grants": [], "actions": {}} + result = presets_mod.register_preset(base) + assert "scope_a" in result["actions"] + assert "scope_b" in result["actions"] + + def test_duplicate_scope_raises_error(self, monkeypatch): + """Two apps defining the same scope raises ImproperlyConfigured.""" + c1 = _fake_app_config("orders", {}) + c2 = _fake_app_config("payments", {}) + _patch_get_app_configs(monkeypatch, [c1, c2]) + _patch_import_module(monkeypatch, { + "orders": { + "PERMISSION_PRESET": { + "actions": {"orders": {"create": {"implies": []}}}, + "roles": [], "groups": [], "role_grants": [], + } + }, + "payments": { + "PERMISSION_PRESET": { + "actions": {"orders": {"refund": {"implies": ["create"]}}}, + "roles": [], "groups": [], "role_grants": [], + } + }, + }) + base = {"roles": [], "groups": [], "role_grants": [], "actions": {}} + with pytest.raises(ImproperlyConfigured, match="already owned"): + presets_mod.register_preset(base) + + def test_duplicate_scope_with_base_preset_raises_error(self, monkeypatch): + """App defining a scope already in the base preset raises error.""" + c1 = _fake_app_config("orders", {}) + _patch_get_app_configs(monkeypatch, [c1]) + _patch_import_module(monkeypatch, { + "orders": { + "PERMISSION_PRESET": { + "actions": {"orders": {"create": {"implies": []}}}, + "roles": [], "groups": [], "role_grants": [], } }, - ): - result = presets_mod.register_preset(base) - - assert len(result["roles"]) == 2 - assert len(result["groups"]) == 1 - assert len(result["role_grants"]) == 1 - - def test_base_without_keys_still_works(self): - """Empty base dict gets default keys.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], - {"blog": {"PERMISSION_PRESET": {"roles": [{"slug": "author"}]}}}, - ): - result = presets_mod.register_preset({}) - - assert "roles" in result - assert "groups" in result - assert "role_grants" in result - assert len(result["roles"]) == 1 - - -class TestRegisterAccessScopes: - """Tests for register_access_scopes().""" - - @override_settings(ACCESS_SCOPES=["existing"]) - def test_merges_discovered_into_settings(self): - """Discovered scopes are appended to the existing list.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], {"blog": {"ACCESS_SCOPES": ["posts", "comments"]}} - ): - presets_mod.register_access_scopes() - - assert django_settings.ACCESS_SCOPES == ["existing", "posts", "comments"] - - @override_settings(ACCESS_SCOPES=[]) - def test_works_when_setting_not_defined(self): - """If ACCESS_SCOPES is empty, starts from scratch.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], {"blog": {"ACCESS_SCOPES": ["posts"]}} - ): - presets_mod.register_access_scopes() - - assert django_settings.ACCESS_SCOPES == ["posts"] - - @override_settings(ACCESS_SCOPES=["common"]) - def test_no_duplicates(self): - """Duplicates between existing and discovered are not added.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], {"blog": {"ACCESS_SCOPES": ["common", "posts"]}} - ): - presets_mod.register_access_scopes() - - assert django_settings.ACCESS_SCOPES == ["common", "posts"] - - -class TestRegisterAccessApplications: - """Tests for register_access_applications().""" - - @override_settings(ACCESS_APPLICATIONS=["existing"]) - def test_merges_discovered_into_settings(self): - """Discovered app names are appended to the existing list.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], {"blog": {"ACCESS_APPLICATION_NAME": "crm"}} - ): - presets_mod.register_access_applications() - - assert django_settings.ACCESS_APPLICATIONS == ["existing", "crm"] - - @override_settings() - def test_works_when_setting_not_defined(self): - """If ACCESS_APPLICATIONS is not in settings, starts from empty.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], {"blog": {"ACCESS_APPLICATION_NAME": "crm"}} - ): - presets_mod.register_access_applications() - - assert django_settings.ACCESS_APPLICATIONS == ["crm"] - - @override_settings(ACCESS_APPLICATIONS=["crm"]) - def test_no_duplicates(self): - """Duplicates between existing and discovered are not added.""" - cfg = _fake_app_config("blog") - with _patch_discovery( - [cfg], {"blog": {"ACCESS_APPLICATION_NAME": "crm"}} - ): - presets_mod.register_access_applications() - - assert django_settings.ACCESS_APPLICATIONS == ["crm"] + }) + base = { + "roles": [], "groups": [], "role_grants": [], + "actions": {"orders": {"read": {"implies": []}}}, + } + with pytest.raises(ImproperlyConfigured, match="already owned"): + presets_mod.register_preset(base) diff --git a/tests/permissions/test_role_sync.py b/tests/permissions/test_role_sync.py index 8015234..ac0c872 100644 --- a/tests/permissions/test_role_sync.py +++ b/tests/permissions/test_role_sync.py @@ -1,5 +1,5 @@ """ -Tests for role_sync functionality. +Tests for role_sync functionality (refactored — named actions). """ import pytest from django.contrib.auth import get_user_model @@ -64,143 +64,137 @@ def editor_role_grant(db_setup, editor_role): return RoleGrant.objects.create( role=editor_role, scope='articles', - actions=['r', 'w'], + actions=['read', 'write'], context={} ) class TestRoleSync: - """Test role_sync functionality.""" + """Test role_sync functionality with named actions.""" def test_role_sync_updates_independent_grants(self, test_user, editor_role, editor_role_grant, admin_user): """Test role_sync updates independent role grants after RoleGrant changes.""" - # Assign role independently (not via group) assign_role(test_user, 'editor', 'articles', by=admin_user) - - # Verify initial grant + + # Verify initial grant (write implies read) grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role, user_group__isnull=True) - assert set(grant.actions) == {'r', 'w'} - + assert set(grant.actions) == {'read', 'write'} + # Modify role grant - editor_role_grant.actions = ['r', 'w', 'd'] + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - + # Sync role stats = role_sync('editor') - + assert stats['grants_updated'] == 1 - + # Check grant was updated grant.refresh_from_db() - assert 'd' in grant.actions + assert 'delete' in grant.actions def test_role_sync_with_scope_filter(self, test_user, editor_role, editor_role_grant, admin_user): - """Test role_sync with scope parameter for performance optimization.""" + """Test role_sync with scope parameter.""" # Create another role grant for different scope comments_grant = RoleGrant.objects.create( role=editor_role, scope='comments', - actions=['r'], + actions=['read'], context={} ) - - # Assign role independently + assign_role(test_user, 'editor', 'articles', by=admin_user) assign_role(test_user, 'editor', 'comments', by=admin_user) - + # Modify editor role grant for articles - editor_role_grant.actions = ['r', 'w', 'd'] + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - + # Sync only articles scope stats = role_sync('editor', scope='articles') - + assert stats['grants_updated'] == 1 - + # Check articles grant was updated articles_grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role, user_group__isnull=True) - assert 'd' in articles_grant.actions - + assert 'delete' in articles_grant.actions + # Check comments grant was NOT updated comments_grant_obj = Grant.objects.get(user=test_user, scope='comments', role=editor_role, user_group__isnull=True) - assert set(comments_grant_obj.actions) == {'r'} + assert set(comments_grant_obj.actions) == {'read'} def test_role_sync_multiple_users(self, test_user, test_user2, editor_role, editor_role_grant, admin_user): """Test role_sync updates grants for all users with independent role assignments.""" - # Assign role to multiple users independently assign_role(test_user, 'editor', 'articles', by=admin_user) assign_role(test_user2, 'editor', 'articles', by=admin_user) - + # Modify role grant - editor_role_grant.actions = ['r', 'w', 'd'] + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - + # Sync role stats = role_sync('editor') - + assert stats['grants_updated'] == 2 - + # Check both grants were updated grant1 = Grant.objects.get(user=test_user, scope='articles', role=editor_role, user_group__isnull=True) grant2 = Grant.objects.get(user=test_user2, scope='articles', role=editor_role, user_group__isnull=True) - assert 'd' in grant1.actions - assert 'd' in grant2.actions + assert 'delete' in grant1.actions + assert 'delete' in grant2.actions def test_role_sync_preserves_locked_grants(self, test_user, editor_role, editor_role_grant, admin_user): """Test role_sync does not update locked (custom) grants.""" - # Assign role independently assign_role(test_user, 'editor', 'articles', by=admin_user) - + # Lock the grant (simulate override_grant) grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role, user_group__isnull=True) grant.locked = True - grant.actions = ['r'] # Custom actions + grant.actions = ['read'] # Custom actions grant.save() - + # Modify role grant - editor_role_grant.actions = ['r', 'w', 'd'] + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - + # Sync role stats = role_sync('editor') - + assert stats['grants_updated'] == 0 # Locked grant not updated - + # Check grant was NOT updated grant.refresh_from_db() - assert set(grant.actions) == {'r'} - assert 'd' not in grant.actions + assert set(grant.actions) == {'read'} + assert 'delete' not in grant.actions def test_role_sync_ignores_group_grants(self, test_user, editor_role, editor_role_grant, admin_user): """Test role_sync only updates independent grants, not group-based grants.""" - # Create a grant with user_group (simulating group assignment) from oxutils.permissions.models import Group, UserGroup - + group = Group.objects.create(slug='staff', name='Staff') group.roles.add(editor_role) user_group = UserGroup.objects.create(user=test_user, group=group) - + Grant.objects.create( user=test_user, scope='articles', role=editor_role, - actions=['r', 'w'], + actions=['read', 'write'], user_group=user_group, locked=False ) - + # Modify role grant - editor_role_grant.actions = ['r', 'w', 'd'] + editor_role_grant.actions = ['read', 'write', 'delete'] editor_role_grant.save() - + # Sync role stats = role_sync('editor') - + assert stats['grants_updated'] == 0 # Group grant not updated by role_sync - - # Check grant was NOT updated + grant = Grant.objects.get(user=test_user, scope='articles', role=editor_role, user_group=user_group) - assert 'd' not in grant.actions + assert 'delete' not in grant.actions def test_role_sync_role_not_found(self, db_setup): """Test role_sync raises exception for non-existent role.""" @@ -209,7 +203,5 @@ def test_role_sync_role_not_found(self, db_setup): def test_role_sync_no_grants(self, editor_role, editor_role_grant): """Test role_sync with no grants to update.""" - # No users have this role independently stats = role_sync('editor') - assert stats['grants_updated'] == 0 diff --git a/tests/settings.py b/tests/settings.py index c5d5687..202bf01 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -91,10 +91,63 @@ ACCESS_MANAGER_GROUP = 'manager' ACCESS_MANAGER_ROLE = 'manager' ACCESS_MANAGER_CONTEXT = {} -ACCESS_SCOPES = ['access', 'articles', 'users', 'comments'] +ACCESS_SCOPES = ['access', 'articles', 'users', 'comments', 'orders'] CACHE_CHECK_PERMISSION = False FIELD_MASKING_KEY = 'LCPN2bFN2NHA6XCZscpv8JctYJQ2FTfuVKIunFUchnE=' +PERMISSION_PRESET = { + "actions": { + "articles": { + "read": {"implies": [], "label": "Read"}, + "write": {"implies": ["read"], "label": "Write"}, + "delete": {"implies": ["read", "write"], "label": "Delete"}, + "update": {"implies": ["read"], "label": "Update"}, + "publish": {"implies": ["write"], "label": "Publish"}, + "archive": {"implies": ["publish"], "label": "Archive"}, + }, + "users": { + "read": {"implies": [], "label": "Read"}, + "write": {"implies": ["read"], "label": "Write"}, + "delete": {"implies": ["read", "write"], "label": "Delete"}, + "invite": {"implies": [], "label": "Invite"}, + }, + "comments": { + "read": {"implies": [], "label": "Read"}, + "write": {"implies": ["read"], "label": "Write"}, + "moderate": {"implies": ["read", "write"], "label": "Moderate"}, + "delete": {"implies": ["read", "moderate"], "label": "Delete"}, + }, + "orders": { + "create": {"implies": [], "label": "Create"}, + "approve": {"implies": ["create"], "label": "Approve"}, + "cancel": {"implies": [], "label": "Cancel"}, + "refund": {"implies": ["approve"], "label": "Refund"}, + "read": {"implies": [], "label": "Read"}, + }, + }, + "roles": [ + { + "slug": "manager", + "name": "Manager", + }, + ], + "groups": [ + { + "slug": "manager", + "name": "Manager", + "roles": ["manager"], + }, + ], + "role_grants": [ + { + "role": "manager", + "scope": "access", + "actions": ["read", "write"], + "context": {}, + }, + ], +} + # Django Allauth / Auth settings SITE_ID = 1 OXI_COOKIE_DOMAIN = 'example.com' diff --git a/uv.lock b/uv.lock index 3715433..fbb11cc 100644 --- a/uv.lock +++ b/uv.lock @@ -1356,7 +1356,7 @@ wheels = [ [[package]] name = "oxutils" -version = "0.4.5" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "bcc-rates" },