Verifikation: Alle 17 Audit-Findings gegen den Code geprueft — alle bestaetigt. Backend-Lifecycle-Fixes umgesetzt; 4 Frontend-Plugin-Architektur-Punkte als Phase Q in die Roadmap eingeplant. - P1 list_workspaces: Module + User-Counts gebuendelt laden (Editor-Overwrite-Bug) - P1 active-manifests: Tenant-Deaktivierung (tenant_plugin_activation) filtern - P1 uninstall: volle Service-Deactivation VOR registry.uninstall() - P1 ContractRegistry: DB-Aktivstatus-Guard (Restart-Edge-Case) + Re-Activate - P1/P2 Field-Definitions: voller Lifecycle (register/unregister) im Service - P1/P2 Contact-Felddefinitionen (39) ins ContactsPlugin-Manifest verschoben - P1 12 fehlende Permission-Keys registriert (AST-Scan: 0 fehlend) - P2 contact_folder -> ContactsPlugin; ENTITY_PLUGIN_OWNERS wird befuellt - P2 Entity-Permission-Fallback fail-closed statt contacts:read - P2 forgejo_error_reporter is_core=False; DMS is_core=True (ADR-020) - P2 Worker: Contacts-Trash-Cleanup ins Plugin (get_job_modules-Discovery) - P1/P2 DSGVO-Export delegiert an DSAR-Collector (kein Core->Contacts) - P2 False-green Tests korrigiert (or True, veraltete Route-Count-Assertion) Verifikation: tests/test_audit_architecture_fixes.py 17/17; Regressionen gruen (contacts_lifecycle, entity_registry, workspace_scopes, rbac, lifecycle_service); Combo-Order-Test 35/35; Cross-Plugin-Checker 497/0; compileall sauber; ruff auf 7-Error-Baseline. Doku: PROGRESS.md Audit-Section, PLATFORM_ROADMAP.md Phase Q (Q1-Q4), plugin-development-guide.md Lifecycle, permissions.md Katalog.
12 KiB
LeoCRM Permission System
Version: 1.0
Date: 2026-07-29
Applies to: All developers and system administrators
1. Architecture Overview
The LeoCRM permission system is a multi-layered access control framework that combines:
- Row-Level Ownership — Each entity can have an
owner_id(user who owns it) - Entity Permissions (ACL) — Explicit permission entries for users, groups, or roles
- ABAC Policies — Attribute-based policies for fine-grained access control
- Role-Based Access Control (RBAC) — Module-level permissions via user roles
- System Admin Override — System administrators see everything
Permission Resolution Order
When checking access to an entity, the system resolves in this order (highest wins):
- System Admin →
delete(full access to everything) - Owner →
owner(fromowner_idon the entity) - Direct User Permission → explicit ACL entry for the user
- Group Permission → ACL entry for a group the user belongs to
- Role Permission → ACL entry for the user's role
- Tenant-Owned →
read(ifowner_id IS NULL, visible to all with module permission) - No Access →
none
Permission Levels
| Level | Value | Description |
|---|---|---|
none |
0 | Explicit deny (overrides allow) |
read |
1 | View the entity |
write |
2 | Read + edit entity fields |
admin |
3 | Write + delete + manage permissions |
delete |
4 | Admin + transfer ownership |
owner |
5 | Full control (automatic for owner) |
Permission Name Schema (canonical)
All module-level permission strings follow the strict 2-segment schema
module:action, with * wildcards allowed in either segment:
| Pattern | Meaning |
|---|---|
contacts:read |
Exact: read contacts |
contacts:* |
All actions on contacts |
*:read |
Read on all modules |
*:* |
Everything (superadmin) |
The runtime matcher (app/core/permissions.py::_matches_permission) compares
segment counts strictly — a 3-segment grant like core:contacts:read can never
match any 2-segment requirement and is therefore invalid. The plugin manifest
validator (app/plugins/manifest.py) rejects such patterns at load time.
Historical note (ARCH-008/009): migration 0019 seeded default roles with dead
3-segment patterns (core:*:read etc.); migration 0141 converts existing role
data to the canonical form.
Catalog completeness (audit fix 2026-09-13): 12 previously used-but-unregistered keys are now in the catalog so roles can actually be granted them:
CORE_PERMISSIONSadditions:automation:admin,bank-accounts:read,bank-accounts:write,delegations:read,delegations:write,policies:read,policies:write,templates:read,templates:writepermissionsplugin manifest:permissions:read,permissions:adminforgejo_error_reporterplugin manifest:system:read
Verified via AST scan (used keys vs. catalog): 146 registered, 0 missing.
Entity-permission mapping fails closed (audit fix): get_entity_read_permission() no longer falls back to contacts:read for unmapped entities — it returns the un-grantable sentinel __unmapped__:read (generic services then deny). Unknown entity types are rejected earlier with 422 by validate_entity_type(). Plugin-owned entities resolve correctly via ENTITY_PLUGIN_OWNERS (now populated through register_entity_model(..., plugin_name=...)).
2. Data Model
EntityPermission
Stored in the entity_permissions table:
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key |
tenant_id |
UUID | Tenant scope |
entity_type |
String(50) | Entity type (e.g., 'contact', 'dms_file') |
entity_id |
UUID | The specific entity |
principal_type |
String(10) | 'user', 'group', 'role', 'guest' |
principal_id |
UUID | The user/group/role ID |
permission_level |
String(20) | 'none', 'read', 'write', 'admin', 'delete' |
expires_at |
DateTime | Optional expiration |
created_by |
UUID | Who created this permission |
created_at |
DateTime | Creation timestamp |
updated_at |
DateTime | Last update timestamp |
EntityPolicy (ABAC)
Stored in the entity_policies table:
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key |
tenant_id |
UUID | Tenant scope |
name |
String(200) | Policy name |
entity_type |
String(50) | Target entity type |
principal_type |
String(10) | 'user', 'group', 'role' |
principal_id |
UUID | Target principal |
effect |
String(10) | 'allow' or 'deny' |
conditions |
JSONB | Attribute-based conditions |
priority |
Integer | Evaluation priority (higher = first) |
enabled |
Boolean | Whether the policy is active |
created_at |
DateTime | Creation timestamp |
updated_at |
DateTime | Last update timestamp |
OwnedMixin
Adds owner_id to any model:
NULL→ Tenant-owned (visible to all with module permission)UUID→ Owned by that user- Set automatically by service layer on creation
- Transfer requires owner, admin, or system_admin role
3. Entity Permissions API
List Permissions
GET /api/v1/{entity_type}/{entity_id}/permissions
Returns all permission entries for an entity.
Create/Update Permission
POST /api/v1/{entity_type}/{entity_id}/permissions
{
"user_id": "uuid",
"access_level": "read",
"expires_at": "2026-12-31T23:59:59Z"
}
Revoke Permission
DELETE /api/v1/{entity_type}/{entity_id}/permissions/{user_id}
Check Access
GET /api/v1/{entity_type}/{entity_id}/access?user_id={uuid}
Returns the effective access level for a user.
4. ABAC Policies
Policy Conditions Format
{
"operator": "AND",
"rules": [
{"field": "status", "op": "eq", "value": "active"},
{"field": "amount", "op": "gte", "value": 1000},
{"field": "tags", "op": "contains", "value": "vip"}
]
}
Supported Operators
| Operator | Description | Example |
|---|---|---|
eq |
Equals | {"field": "type", "op": "eq", "value": "company"} |
neq |
Not equals | {"field": "status", "op": "neq", "value": "archived"} |
gt |
Greater than | {"field": "amount", "op": "gt", "value": 100} |
gte |
Greater or equal | {"field": "amount", "op": "gte", "value": 50} |
lt |
Less than | {"field": "amount", "op": "lt", "value": 10000} |
lte |
Less or equal | {"field": "amount", "op": "lte", "value": 500} |
in |
In list | {"field": "status", "op": "in", "value": ["active", "pending"]} |
not_in |
Not in list | {"field": "status", "op": "not_in", "value": ["deleted"]} |
contains |
String contains | {"field": "name", "op": "contains", "value": "VIP"} |
starts_with |
String starts with | {"field": "name", "op": "starts_with", "value": "Confidential"} |
is_null |
Is NULL | {"field": "email", "op": "is_null"} |
is_not_null |
Is not NULL | {"field": "email", "op": "is_not_null"} |
Policy Evaluation
- Allow policies: OR-joined (at least one must match for access)
- Deny policies: NOT (none may match — deny takes precedence)
- Priority: Higher priority policies evaluated first
- Enabled flag: Disabled policies are skipped
5. Service Layer
Entity Permission Service (app/services/entity_permission_service.py)
| Function | Description |
|---|---|
get_effective_access() |
Get access level for a user on a specific entity |
get_visible_ids() |
Get all visible entity IDs for a user |
batch_get_effective_access() |
Batch resolve access for multiple entities |
check_entity_access() |
Check if user has at least required level |
get_cached_visible_ids() |
Get visible IDs with Redis caching |
create_permission() |
Create or update a permission entry |
update_permission() |
Update an existing permission |
delete_permission() |
Delete a permission entry |
list_permissions() |
List all permissions for an entity |
list_all_permissions() |
List all permissions for a tenant |
cleanup_expired_permissions() |
Remove expired permission entries |
invalidate_all_user_entity_cache() |
Clear all cached permissions for a user |
get_permission_analytics() |
Get permission statistics |
Policy Service (app/services/policy_service.py)
| Function | Description |
|---|---|
create_policy() |
Create a new ABAC policy |
update_policy() |
Update an existing policy |
delete_policy() |
Delete a policy |
list_policies() |
List policies for a tenant |
build_sql_condition() |
Translate JSONB conditions to SQLAlchemy filters |
apply_policy_filter() |
Apply ABAC policies to a query |
6. Caching
The permission system uses Redis for caching visibility results:
- Cache key:
ep_vis:{user_id}:{tenant_id}:{entity_type} - Cache value: JSON with
visible_idsandaccess_map - TTL: 5 minutes (300 seconds)
- Invalidation: Automatic on permission create/update/delete
Cache Flow
- Check Redis cache for user + entity type
- Cache hit → return cached visible IDs
- Cache miss → resolve from database, store in cache
- Permission changes → invalidate affected user caches
7. Performance Considerations
- Batch resolution (
batch_get_effective_access) is preferred over individualget_effective_accesscalls - Redis caching reduces database load for repeated visibility checks
- Bitmap optimization for large entity sets (planned)
- Indexes on
entity_type + entity_id,principal_type + principal_id,tenant_id,expires_at - Partitioning recommended for
entity_permissionstable at scale
8. Security Considerations
- Deny takes precedence over allow in both ACL and ABAC
- Expired permissions are automatically excluded from resolution
- System admin bypasses all permission checks
- Audit logging for all permission changes
- Notifications sent to users when permissions are granted/revoked
- Tenant isolation enforced via
tenant_idon all permission entries
9. Examples
Grant Read Access to a User
from app.services import entity_permission_service as eps
await eps.create_permission(
db_session,
tenant_id=tenant_id,
entity_type="contact",
entity_id=str(contact_id),
principal_type="user",
principal_id=str(user_id),
permission_level="read",
created_by=current_user.id,
)
Check Access
access = await eps.get_effective_access(
db_session, tenant_id, user_id, "contact", contact_id
)
if access in ("read", "write", "admin", "delete", "owner"):
# User has access
pass
Create ABAC Policy
from app.services import policy_service as ps
policy = await ps.create_policy(
db_session,
tenant_id=tenant_id,
name="VIP Only",
entity_type="contact",
principal_type="user",
principal_id=str(user_id),
effect="allow",
conditions={
"operator": "AND",
"rules": [
{"field": "type", "op": "eq", "value": "company"},
{"field": "name", "op": "contains", "value": "VIP"},
]
},
)
Batch Resolve Access
result = await eps.batch_get_effective_access(
db_session, tenant_id, user_id, "contact", entity_ids
)
for entity_id, level in result.items():
print(f"Entity {entity_id}: {level}")
10. Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| User sees nothing | No permissions, not owner, not system admin | Grant explicit permission or set owner_id |
| Expired permission still works | Cache not invalidated | Wait for TTL or invalidate cache manually |
| ABAC policy not applied | Policy disabled or no conditions match | Check enabled flag and conditions |
| System admin can't see entity | Entity deleted or wrong tenant | Check deleted_at and tenant_id |
| Permission creation fails | Duplicate unique constraint | Use upsert (create_permission handles this) |
Debugging
Enable debug logging:
import logging
logging.getLogger("app.services.entity_permission_service").setLevel(logging.DEBUG)
logging.getLogger("app.services.policy_service").setLevel(logging.DEBUG)