Files
leocrm/docs/permissions_plugin_dev.md
T

510 lines
13 KiB
Markdown

# Permission System Plugin Development Guide
> **Version:** 1.0
> **Date:** 2026-07-29
> **Applies to:** Plugin developers integrating with the LeoCRM permission system
---
## 1. Overview
Plugins can leverage the LeoCRM permission system to add row-level access control to their entities. This guide covers:
- Adding `OwnedMixin` to plugin models
- Using `apply_visibility_filter()` for list queries
- Registering entity types for permission management
- Defining field-level permissions
- Integrating with ABAC policies
---
## 2. Adding OwnedMixin to Plugin Models
To enable ownership tracking for your plugin's entities, add `OwnedMixin` to your SQLAlchemy model:
```python
from app.models.owned_mixin import OwnedMixin
from app.core.db import Base, TenantMixin
class MyEntity(Base, TenantMixin, OwnedMixin):
__tablename__ = "my_entities"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
# ... other fields
```
### Owner ID Semantics
- `NULL` → Tenant-owned (visible to all with module permission)
- `UUID` → Owned by that user (visible to owner + shared via entity_permissions)
- Set automatically by service layer on creation
- Transfer requires owner, admin, or system_admin role
### Setting Owner on Creation
```python
from app.models.owned_mixin import OwnedMixin
async def create_entity(db: AsyncSession, data: dict, current_user: User):
entity = MyEntity(
tenant_id=current_user.tenant_id,
owner_id=current_user.id, # Set owner automatically
**data,
)
db.add(entity)
await db.commit()
await db.refresh(entity)
return entity
```
---
## 3. Using apply_visibility_filter()
The `apply_visibility_filter()` function filters a query to only return entities the user can see. This is the recommended way to implement list endpoints.
### Basic Usage
```python
from app.services import entity_permission_service as eps
async def list_entities(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> list[MyEntity]:
# Get visible entity IDs
visible_ids, access_map = await eps.get_visible_ids(
db, tenant_id, user_id, "my_entity"
)
if not visible_ids:
return []
# Query only visible entities
result = await db.execute(
select(MyEntity)
.where(MyEntity.id.in_(visible_ids))
.where(MyEntity.tenant_id == tenant_id)
)
return result.scalars().all()
```
### With Caching
```python
async def list_entities_cached(
db: AsyncSession,
redis: aioredis.Redis,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> list[MyEntity]:
# Use cached version for better performance
visible_ids, access_map = await eps.get_cached_visible_ids(
db, redis, tenant_id, user_id, "my_entity"
)
if not visible_ids:
return []
result = await db.execute(
select(MyEntity)
.where(MyEntity.id.in_(visible_ids))
.where(MyEntity.tenant_id == tenant_id)
)
return result.scalars().all()
```
### With Access Level in Response
```python
async def list_entities_with_access(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> list[dict]:
visible_ids, access_map = await eps.get_visible_ids(
db, tenant_id, user_id, "my_entity"
)
if not visible_ids:
return []
result = await db.execute(
select(MyEntity)
.where(MyEntity.id.in_(visible_ids))
.where(MyEntity.tenant_id == tenant_id)
)
entities = result.scalars().all()
return [
{
**entity.to_dict(),
"access_level": access_map.get(entity.id, "none"),
}
for entity in entities
]
```
---
## 4. Entity Registration
Register your entity type so it appears in the permission management UI and API.
### In Your Plugin Manifest
```python
from app.plugins.manifest import PluginManifest
class MyPluginManifest(PluginManifest):
# ... other fields
entity_types: list[str] = ["my_entity"]
```
### In Your Plugin Class
```python
from app.plugins.base import BasePlugin
class MyPlugin(BasePlugin):
@property
def entity_types(self) -> list[str]:
return ["my_entity"]
```
### Registering Routes for Permission Management
```python
from fastapi import APIRouter, Depends
router = APIRouter(prefix="/api/v1/my-entities")
@router.get("/{entity_id}/permissions")
async def list_my_entity_permissions(
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all permissions for a specific entity."""
return await eps.list_permissions(
db, current_user.tenant_id, "my_entity", str(entity_id)
)
@router.post("/{entity_id}/permissions")
async def create_my_entity_permission(
entity_id: uuid.UUID,
body: PermissionCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Grant permission to a user for this entity."""
return await eps.create_permission(
db,
tenant_id=current_user.tenant_id,
entity_type="my_entity",
entity_id=str(entity_id),
principal_type="user",
principal_id=str(body.user_id),
permission_level=body.access_level,
expires_at=body.expires_at,
created_by=current_user.id,
)
```
---
## 5. Field Definitions
Define which fields of your entity are visible at each permission level.
### Field Permission Schema
```python
from pydantic import BaseModel
from typing import Any
class EntityFieldDefinition(BaseModel):
"""Define field visibility per permission level."""
name: str
type: str # "string", "number", "boolean", "date", "reference"
required: bool = False
readable: dict[str, bool] = {
"read": True,
"write": True,
"admin": True,
"delete": True,
"owner": True,
}
writable: dict[str, bool] = {
"read": False,
"write": True,
"admin": True,
"delete": True,
"owner": True,
}
```
### Registering Field Definitions
```python
from app.core.permission_registry import register_entity_fields
FIELD_DEFINITIONS = [
EntityFieldDefinition(
name="name",
type="string",
required=True,
readable={"read": True, "write": True, "admin": True, "delete": True, "owner": True},
writable={"read": False, "write": True, "admin": True, "delete": True, "owner": True},
),
EntityFieldDefinition(
name="sensitive_data",
type="string",
required=False,
readable={"read": False, "write": False, "admin": True, "delete": True, "owner": True},
writable={"read": False, "write": False, "admin": True, "delete": True, "owner": True},
),
]
# Register during plugin activation
register_entity_fields("my_entity", FIELD_DEFINITIONS)
```
---
## 6. Integrating with ABAC Policies
Your plugin can create and manage ABAC policies for its entities.
### Creating Policies
```python
from app.services import policy_service as ps
# Create an allow policy
policy = await ps.create_policy(
db_session,
tenant_id=tenant_id,
name="Allow VIP my_entities",
entity_type="my_entity",
principal_type="user",
principal_id=str(user_id),
effect="allow",
conditions={
"operator": "AND",
"rules": [
{"field": "priority", "op": "gte", "value": 10},
]
},
)
```
### Applying Policies to Queries
```python
query = select(MyEntity).where(MyEntity.tenant_id == tenant_id)
query = await ps.apply_policy_filter(
db_session, query, "my_entity", user_id, tenant_id, MyEntity
)
result = await db_session.execute(query)
entities = result.scalars().all()
```
---
## 7. Best Practices
### Do's
- ✅ Always set `owner_id` on entity creation
- ✅ Use `get_visible_ids()` for list endpoints
- ✅ Use `batch_get_effective_access()` for bulk operations
- ✅ Register entity types for permission management
- ✅ Define field-level permissions for sensitive data
- ✅ Use Redis caching for frequently accessed permissions
- ✅ Handle permission expiration gracefully
### Don'ts
- ❌ Don't bypass permission checks for list endpoints
- ❌ Don't expose `owner_id` changes without authorization
- ❌ Don't create permissions without audit logging
- ❌ Don't forget to invalidate cache after permission changes
- ❌ Don't use `get_effective_access()` in loops — use batch instead
---
## 8. Testing
### Test Fixtures
```python
import pytest
from app.services import entity_permission_service as eps
from tests.conftest import seed_tenant_and_users
@pytest.mark.asyncio
async def test_my_entity_permissions(db_session):
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
user_id = seed["admin_a"].id
# Create entity with owner
entity = MyEntity(
tenant_id=tenant_id,
owner_id=user_id,
name="Test Entity",
)
db_session.add(entity)
await db_session.commit()
# Check access
access = await eps.get_effective_access(
db_session, tenant_id, user_id, "my_entity", entity.id
)
assert access == "owner"
```
### Mocking Permissions
```python
from unittest.mock import AsyncMock, patch
async def test_list_with_mock_permissions():
with patch(
"app.services.entity_permission_service.get_visible_ids",
new=AsyncMock(return_value=({uuid.UUID(int=1)}, {uuid.UUID(int=1): "read"})),
):
# Your test code
pass
```
---
## 9. API Reference
### Entity Permission Service
| Function | Import | Description |
|----------|--------|-------------|
| `get_effective_access()` | `from app.services import entity_permission_service as eps` | Get access level for a user on a specific entity |
| `get_visible_ids()` | Same | Get all visible entity IDs for a user |
| `batch_get_effective_access()` | Same | Batch resolve access for multiple entities |
| `check_entity_access()` | Same | Check if user has at least required level |
| `get_cached_visible_ids()` | Same | Get visible IDs with Redis caching |
| `create_permission()` | Same | Create or update a permission entry |
| `update_permission()` | Same | Update an existing permission |
| `delete_permission()` | Same | Delete a permission entry |
| `list_permissions()` | Same | List all permissions for an entity |
### Policy Service
| Function | Import | Description |
|----------|--------|-------------|
| `create_policy()` | `from app.services import policy_service as ps` | Create a new ABAC policy |
| `update_policy()` | Same | Update an existing policy |
| `delete_policy()` | Same | Delete a policy |
| `list_policies()` | Same | List policies for a tenant |
| `build_sql_condition()` | Same | Translate JSONB conditions to SQLAlchemy filters |
| `apply_policy_filter()` | Same | Apply ABAC policies to a query |
---
## 10. Example: Complete Plugin Integration
```python
"""Example plugin with full permission system integration."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
from app.services import entity_permission_service as eps
class MyEntity(Base, TenantMixin, OwnedMixin):
"""Example entity with permission support."""
__tablename__ = "my_entities"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
priority: Mapped[int] = mapped_column(nullable=False, default=0)
async def create_my_entity(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
name: str,
priority: int = 0,
) -> MyEntity:
"""Create a new entity with owner set."""
entity = MyEntity(
tenant_id=tenant_id,
owner_id=user_id,
name=name,
priority=priority,
)
db.add(entity)
await db.commit()
await db.refresh(entity)
return entity
async def list_visible_entities(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> list[MyEntity]:
"""List entities visible to the user."""
visible_ids, _ = await eps.get_visible_ids(
db, tenant_id, user_id, "my_entity"
)
if not visible_ids:
return []
from sqlalchemy import select
result = await db.execute(
select(MyEntity)
.where(MyEntity.id.in_(visible_ids))
.where(MyEntity.tenant_id == tenant_id)
)
return result.scalars().all()
async def share_entity(
db: AsyncSession,
tenant_id: uuid.UUID,
entity_id: uuid.UUID,
target_user_id: uuid.UUID,
level: str,
created_by: uuid.UUID,
) -> dict:
"""Share an entity with another user."""
return await eps.create_permission(
db,
tenant_id=tenant_id,
entity_type="my_entity",
entity_id=str(entity_id),
principal_type="user",
principal_id=str(target_user_id),
permission_level=level,
created_by=created_by,
)
```