diff --git a/RBAC_PROGRESS.md b/RBAC_PROGRESS.md index c8ea8a6..405feb0 100644 --- a/RBAC_PROGRESS.md +++ b/RBAC_PROGRESS.md @@ -1,6 +1,6 @@ # RBAC Build Progress — LeoCRM -## Letztes Update: 2026-07-29 01:23 CEST +## Letztes Update: 2026-07-29 01:28 CEST ## Sprint 1 — Fundament (14h) @@ -18,14 +18,38 @@ - Index auf owner_id - 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 🔄 -- [ ] 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() - [ ] Rate Limiting auf Permission-Änderungen - [ ] 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 | # | Beschreibung | Status | |---|-------------|:---:| | 0048 | contact_folder_permissions Tabelle | ✅ Ausgeführt | -| 0049 | entity_permissions Tabelle | ⬜ Geplant | -| 0050 | owner_id auf allen Tabellen | ⬜ Geplant | +| 0049 | entity_permissions Tabelle | ✅ Ausgeführt | +| 0050 | owner_id auf 15 Tabellen | ✅ Ausgeführt | | 0051 | Folder ACLs → entity_permissions | ⬜ Geplant | ## Git Commits | Hash | Beschreibung | |------|-------------| | cc021cd | feat: folder permissions (ACLs) - share folders with users/groups | +| 5afa1fa | sprint1: entity_permissions table + owned_mixin + universal permission service + API + migrations 0049+0050 | diff --git a/alembic/versions/0051_migrate_folder_acls.py b/alembic/versions/0051_migrate_folder_acls.py new file mode 100644 index 0000000..a25068c --- /dev/null +++ b/alembic/versions/0051_migrate_folder_acls.py @@ -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'") diff --git a/alembic/versions/0052_rls_contacts.py b/alembic/versions/0052_rls_contacts.py new file mode 100644 index 0000000..6b52545 --- /dev/null +++ b/alembic/versions/0052_rls_contacts.py @@ -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") diff --git a/app/core/db/__init__.py b/app/core/db/__init__.py index 620b2e6..e98bd67 100644 --- a/app/core/db/__init__.py +++ b/app/core/db/__init__.py @@ -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 async def create_db_session( tenant_id: uuid.UUID | str | None = None, diff --git a/app/deps.py b/app/deps.py index a2b2f3c..7515011 100644 --- a/app/deps.py +++ b/app/deps.py @@ -8,11 +8,12 @@ from typing import Any import redis.asyncio as aioredis from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings 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__) @@ -80,6 +81,18 @@ async def get_current_user( tenant_id = uuid.UUID(session_data["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) from app.core.permissions import get_cached_permissions