sprint1: set_user_context + RLS policies on contacts + folder ACL migration 0051+0052

This commit is contained in:
Agent Zero
2026-07-29 01:30:25 +02:00
parent 5afa1fa927
commit 48647a58e0
5 changed files with 210 additions and 36 deletions
+35 -35
View File
@@ -1,6 +1,6 @@
# RBAC Build Progress — LeoCRM # RBAC Build Progress — LeoCRM
## Letztes Update: 2026-07-29 01:23 CEST ## Letztes Update: 2026-07-29 01:28 CEST
## Sprint 1 — Fundament (14h) ## Sprint 1 — Fundament (14h)
@@ -18,14 +18,38 @@
- Index auf owner_id - Index auf owner_id
- Kann auf jedes Model angewendet werden - Kann auf jedes Model angewendet werden
- [x] Migration 0049: entity_permissions Tabelle — ✅ Ausgeführt in Produktion
- [x] Migration 0050: owner_id auf 15 Tabellen — ✅ Ausgeführt in Produktion
- [x] OwnedMixin auf alle 13 Models angewendet:
- Contact, Address, Attachment, BankAccount, Workflow, Sequence
- SavedFilter, SavedView, Webhook, Notification, CustomFieldDefinition
- EntityHistory, AIConversation
- [x] Universeller Permission Service (`app/services/entity_permission_service.py`, 648 Zeilen)
- list_permissions, create_permission, update_permission, delete_permission
- get_effective_access (Owner → User → Group → Role → None)
- get_visible_ids (alle sichtbaren Datensätze für User)
- batch_get_effective_access (Batch-Resolution für Listen)
- get_cached_visible_ids (Redis Cache, 5 Min TTL)
- check_entity_access (einfacher Check)
- cleanup_expired_permissions (Background Worker)
- [x] Universelle Permission API (`app/routes/entity_permissions.py`, 151 Zeilen)
- GET /api/v1/permissions/{entity_type}/{entity_id}
- POST /api/v1/permissions/{entity_type}/{entity_id}
- PUT /api/v1/permissions/{entity_type}/{entity_id}/{permission_id}
- DELETE /api/v1/permissions/{entity_type}/{entity_id}/{permission_id}
- GET /api/v1/permissions/{entity_type}/{entity_id}/access
- GET /api/v1/permissions/registry
- [x] Schema erstellt (`app/schemas/entity_permission.py`)
- [x] Route in main.py registriert
- [x] Alle Imports getestet — OK
- [x] Container neu gestartet
- [x] Git committed und gepusht (5afa1fa)
### In Bearbeitung 🔄 ### In Bearbeitung 🔄
- [ ] Migration 0049: entity_permissions Tabelle
- [ ] Migration 0050: owner_id auf allen Models
- [ ] OwnedMixin auf alle Models anwenden
- [ ] EntityPermission in models/__init__.py registrieren
- [ ] Universeller Permission Service
- [ ] Universelle Permission API (5 Endpoints)
- [ ] Redis-Cache für Entity-Permissions
- [ ] PostgreSQL RLS Policies + set_user_context() - [ ] PostgreSQL RLS Policies + set_user_context()
- [ ] Rate Limiting auf Permission-Änderungen - [ ] Rate Limiting auf Permission-Änderungen
- [ ] Folder ACLs migrieren (Migration 0051) - [ ] Folder ACLs migrieren (Migration 0051)
@@ -35,40 +59,16 @@
--- ---
## Modelle erstellt in dieser Session
| Datei | Beschreibung |
|-------|-------------|
| app/models/entity_permission.py | Universelle ACL-Tabelle für alle Entities |
| app/models/owned_mixin.py | Mixin für owner_id auf jedem Model |
| app/models/contact_folder_permission.py | Folder-spezifische Permissions (wird migriert) |
## Services erstellt in dieser Session
| Datei | Beschreibung |
|-------|-------------|
| app/services/contact_folder_permission_service.py | Folder Permission Service (wird migriert) |
## Routes erstellt in dieser Session
| Datei | Beschreibung |
|-------|-------------|
| app/routes/contact_folder_permissions.py | Folder Permission CRUD API |
## Frontend erstellt in dieser Session
| Datei | Beschreibung |
|-------|-------------|
| frontend/src/components/contacts/FolderPermissionDialog.tsx | Permission Dialog UI |
| frontend/src/api/contactFolders.ts | Folder Permission API + Typen |
| frontend/src/api/contacts.ts | Folder Permission Hooks |
| frontend/src/components/contacts/ContactFolderTree.tsx | Menu-Eintrag 'Rechte' integriert |
## Migrationen ## Migrationen
| # | Beschreibung | Status | | # | Beschreibung | Status |
|---|-------------|:---:| |---|-------------|:---:|
| 0048 | contact_folder_permissions Tabelle | ✅ Ausgeführt | | 0048 | contact_folder_permissions Tabelle | ✅ Ausgeführt |
| 0049 | entity_permissions Tabelle | ⬜ Geplant | | 0049 | entity_permissions Tabelle | ✅ Ausgeführt |
| 0050 | owner_id auf allen Tabellen | ⬜ Geplant | | 0050 | owner_id auf 15 Tabellen | ✅ Ausgeführt |
| 0051 | Folder ACLs → entity_permissions | ⬜ Geplant | | 0051 | Folder ACLs → entity_permissions | ⬜ Geplant |
## Git Commits ## Git Commits
| Hash | Beschreibung | | Hash | Beschreibung |
|------|-------------| |------|-------------|
| cc021cd | feat: folder permissions (ACLs) - share folders with users/groups | | cc021cd | feat: folder permissions (ACLs) - share folders with users/groups |
| 5afa1fa | sprint1: entity_permissions table + owned_mixin + universal permission service + API + migrations 0049+0050 |
@@ -0,0 +1,41 @@
"""Migrate contact_folder_permissions to universal entity_permissions table.
Revision ID: 0051
Revises: 0050
Create Date: 2026-07-29
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PGUUID
revision = "0051"
down_revision = "0050"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Migrate existing contact_folder_permissions to entity_permissions
op.execute("""
INSERT INTO entity_permissions (id, entity_type, entity_id, principal_type, principal_id, permission_level, tenant_id, created_at, updated_at)
SELECT
gen_random_uuid(),
'contact_folder',
folder_id,
CASE
WHEN user_id IS NOT NULL THEN 'user'
WHEN group_id IS NOT NULL THEN 'group'
END,
COALESCE(user_id, group_id),
permission_level,
tenant_id,
created_at,
updated_at
FROM contact_folder_permissions
ON CONFLICT DO NOTHING
""")
def downgrade() -> None:
op.execute("DELETE FROM entity_permissions WHERE entity_type = 'contact_folder'")
+90
View File
@@ -0,0 +1,90 @@
"""Create PostgreSQL RLS policies for row-level security on contacts.
Revision ID: 0052
Revises: 0051
Create Date: 2026-07-29
This migration enables PostgreSQL Row-Level Security on the contacts table
and creates policies that enforce visibility based on:
1. System admin sees everything
2. Owner sees own rows
3. Tenant-owned (owner_id IS NULL) visible to all
4. Shared via entity_permissions
"""
from alembic import op
revision = "0052"
down_revision = "0051"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Enable RLS on contacts table
op.execute("ALTER TABLE contacts ENABLE ROW LEVEL SECURITY")
# Policy: System admin sees everything
op.execute("""
CREATE POLICY contacts_admin_visible ON contacts
FOR ALL
USING (current_setting('app.is_system_admin', true) = 'true')
""")
# Policy: Owner sees own rows
op.execute("""
CREATE POLICY contacts_owner_visible ON contacts
FOR ALL
USING (
owner_id::text = current_setting('app.current_user_id', true)
)
""")
# Policy: Tenant-owned (owner_id IS NULL) visible to all in tenant
op.execute("""
CREATE POLICY contacts_tenant_owned_visible ON contacts
FOR ALL
USING (owner_id IS NULL)
""")
# Policy: Shared via entity_permissions
op.execute("""
CREATE POLICY contacts_shared_visible ON contacts
FOR ALL
USING (
EXISTS (
SELECT 1 FROM entity_permissions ep
WHERE ep.entity_type = 'contact'
AND ep.entity_id = contacts.id
AND ep.tenant_id = contacts.tenant_id
AND ep.permission_level != 'none'
AND (
ep.expires_at IS NULL OR ep.expires_at > NOW()
)
AND (
(ep.principal_type = 'user'
AND ep.principal_id::text = current_setting('app.current_user_id', true))
OR
(ep.principal_type = 'group'
AND ep.principal_id::text = ANY(
string_to_array(current_setting('app.current_user_groups', true), ',')
))
OR
(ep.principal_type = 'role'
AND ep.principal_id IN (
SELECT ut.role_id FROM user_tenants ut
WHERE ut.user_id::text = current_setting('app.current_user_id', true)
AND ut.tenant_id = contacts.tenant_id
))
)
)
)
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS contacts_shared_visible ON contacts")
op.execute("DROP POLICY IF EXISTS contacts_tenant_owned_visible ON contacts")
op.execute("DROP POLICY IF EXISTS contacts_owner_visible ON contacts")
op.execute("DROP POLICY IF EXISTS contacts_admin_visible ON contacts")
op.execute("ALTER TABLE contacts DISABLE ROW LEVEL SECURITY")
+30
View File
@@ -106,6 +106,36 @@ async def set_tenant_context(session: AsyncSession, tenant_id: uuid.UUID | str)
) )
async def set_user_context(
session: AsyncSession,
user_id: uuid.UUID | str,
group_ids: list[uuid.UUID] | None = None,
is_system_admin: bool = False,
) -> None:
"""Set PostgreSQL session variables for RLS user context.
Sets:
- app.current_user_id: the user's UUID
- app.current_user_groups: comma-separated group UUIDs
- app.is_system_admin: 'true' or 'false'
These are used by PostgreSQL RLS policies to filter rows automatically.
"""
await session.execute(
text("SELECT set_config('app.current_user_id', :uid, true)"),
{"uid": str(user_id)},
)
groups_str = ",".join(str(g) for g in group_ids) if group_ids else ""
await session.execute(
text("SELECT set_config('app.current_user_groups', :groups, true)"),
{"groups": groups_str},
)
await session.execute(
text("SELECT set_config('app.is_system_admin', :admin, true)"),
{"admin": "true" if is_system_admin else "false"},
)
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def create_db_session( async def create_db_session(
tenant_id: uuid.UUID | str | None = None, tenant_id: uuid.UUID | str | None = None,
+14 -1
View File
@@ -8,11 +8,12 @@ from typing import Any
import redis.asyncio as aioredis import redis.asyncio as aioredis
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings from app.config import get_settings
from app.core.auth import get_redis, get_session_data, refresh_session_ttl from app.core.auth import get_redis, get_session_data, refresh_session_ttl
from app.core.db import get_db, set_tenant_context from app.core.db import get_db, set_tenant_context, set_user_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -80,6 +81,18 @@ async def get_current_user(
tenant_id = uuid.UUID(session_data["tenant_id"]) tenant_id = uuid.UUID(session_data["tenant_id"])
await set_tenant_context(db, tenant_id) await set_tenant_context(db, tenant_id)
# Set RLS user context for row-level security
user_id = uuid.UUID(session_data["user_id"])
from app.models.group import UserGroup
groups_q = await db.execute(
select(UserGroup.group_id)
.where(UserGroup.user_id == user_id)
.where(UserGroup.tenant_id == tenant_id)
)
group_ids = [row[0] for row in groups_q]
is_admin = session_data.get("is_system_admin", False)
await set_user_context(db, user_id, group_ids, is_admin)
# Load resolved permissions from cache (or DB on miss) # Load resolved permissions from cache (or DB on miss)
from app.core.permissions import get_cached_permissions from app.core.permissions import get_cached_permissions