Files
leocrm/docs/permissions.md
Agent Zero 4a25ac1379
Check Cross-Plugin Imports / check (push) Has been cancelled
fix(arch): externes Audit — 13 Backend-Fixes (Workspace-Modules, Tenant-Manifeste, Lifecycle, Contracts, Permissions)
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.
2026-09-13 02:25:01 +02:00

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):

  1. System Admindelete (full access to everything)
  2. Ownerowner (from owner_id on the entity)
  3. Direct User Permission → explicit ACL entry for the user
  4. Group Permission → ACL entry for a group the user belongs to
  5. Role Permission → ACL entry for the user's role
  6. Tenant-Ownedread (if owner_id IS NULL, visible to all with module permission)
  7. No Accessnone

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_PERMISSIONS additions: automation:admin, bank-accounts:read, bank-accounts:write, delegations:read, delegations:write, policies:read, policies:write, templates:read, templates:write
  • permissions plugin manifest: permissions:read, permissions:admin
  • forgejo_error_reporter plugin 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

  1. Allow policies: OR-joined (at least one must match for access)
  2. Deny policies: NOT (none may match — deny takes precedence)
  3. Priority: Higher priority policies evaluated first
  4. 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_ids and access_map
  • TTL: 5 minutes (300 seconds)
  • Invalidation: Automatic on permission create/update/delete

Cache Flow

  1. Check Redis cache for user + entity type
  2. Cache hit → return cached visible IDs
  3. Cache miss → resolve from database, store in cache
  4. Permission changes → invalidate affected user caches

7. Performance Considerations

  • Batch resolution (batch_get_effective_access) is preferred over individual get_effective_access calls
  • 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_permissions table 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_id on 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)