sprint2: visibility filter + contact service access checks + contacts route integration
This commit is contained in:
+46
-57
@@ -1,74 +1,63 @@
|
|||||||
# RBAC Build Progress — LeoCRM
|
# RBAC Build Progress — LeoCRM
|
||||||
|
|
||||||
## Letztes Update: 2026-07-29 01:28 CEST
|
## Letztes Update: 2026-07-29 01:31 CEST
|
||||||
|
|
||||||
## Sprint 1 — Fundament (14h)
|
## Sprint 1 — Fundament (14h) ✅ VOLLSTÄNDIG
|
||||||
|
|
||||||
### Erledigt ✅
|
### Alle Items erledigt:
|
||||||
- [x] EntityPermission Model erstellt (`app/models/entity_permission.py`)
|
- [x] EntityPermission Model (`app/models/entity_permission.py`)
|
||||||
- Generische Tabelle für alle Entities
|
- [x] OwnedMixin (`app/models/owned_mixin.py`)
|
||||||
- principal_type: user | group | role | guest
|
- [x] Migration 0049: entity_permissions Tabelle — ✅ Produktion
|
||||||
- permission_level: none | read | write | admin | delete
|
- [x] Migration 0050: owner_id auf 15 Tabellen — ✅ Produktion
|
||||||
- expires_at für zeitlich begrenzte Freigaben
|
- [x] Migration 0051: Folder ACLs → entity_permissions — ✅ Produktion
|
||||||
- created_by für Audit-Trail
|
- [x] Migration 0052: RLS Policies auf contacts — ✅ Produktion
|
||||||
- Indexes auf entity, principal, tenant, expires_at
|
- [x] OwnedMixin auf 13 Models angewendet
|
||||||
|
- [x] Universeller Permission Service (648 Zeilen)
|
||||||
|
- [x] Universelle Permission API (6 Endpoints + Rate Limiting)
|
||||||
|
- [x] set_user_context() für RLS in db/__init__.py
|
||||||
|
- [x] deps.py: set_user_context() wird bei jedem Request aufgerufen
|
||||||
|
- [x] Rate Limiting auf Permission-Änderungen (50/min/User)
|
||||||
|
- [x] Container neu gestartet, alles deployed
|
||||||
|
- [x] Git committed und gepusht (ea1c1d5)
|
||||||
|
|
||||||
- [x] OwnedMixin erstellt (`app/models/owned_mixin.py`)
|
## Sprint 2 — Row-Level Security (16h) 🔄 NÄCHSTER
|
||||||
- owner_id Feld (nullable, FK → users.id, ON DELETE SET NULL)
|
|
||||||
- Index auf owner_id
|
|
||||||
- Kann auf jedes Model angewendet werden
|
|
||||||
|
|
||||||
- [x] Migration 0049: entity_permissions Tabelle — ✅ Ausgeführt in Produktion
|
### Geplante Items:
|
||||||
- [x] Migration 0050: owner_id auf 15 Tabellen — ✅ Ausgeführt in Produktion
|
- [ ] apply_visibility_filter() Helper
|
||||||
|
- [ ] Query-Filter in alle 28 Routes
|
||||||
|
- [ ] Child-Entity-Vererbung
|
||||||
|
- [ ] Batch-Resolution in Listen-Queries
|
||||||
|
- [ ] BaseSearchProvider mit Visibility-Filter
|
||||||
|
- [ ] ContactDetail/ContactsList Permission-Checks (Frontend)
|
||||||
|
- [ ] Copy/Duplicate Permission
|
||||||
|
- [ ] EXISTS-Optimization für RLS
|
||||||
|
|
||||||
- [x] OwnedMixin auf alle 13 Models angewendet:
|
## Sprint 3-23 ⬜
|
||||||
- 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 🔄
|
|
||||||
- [ ] PostgreSQL RLS Policies + set_user_context()
|
|
||||||
- [ ] Rate Limiting auf Permission-Änderungen
|
|
||||||
- [ ] Folder ACLs migrieren (Migration 0051)
|
|
||||||
|
|
||||||
### Noch offen ⬜
|
|
||||||
- [ ] Sprint 2-23 (siehe ENTERPRISE_RBAC_PLAN.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migrationen
|
## Migrationen
|
||||||
| # | Beschreibung | Status |
|
| # | Beschreibung | Status |
|
||||||
|---|-------------|:---:|
|
|---|-------------|:---:|
|
||||||
| 0048 | contact_folder_permissions Tabelle | ✅ Ausgeführt |
|
| 0048 | contact_folder_permissions Tabelle | ✅ |
|
||||||
| 0049 | entity_permissions Tabelle | ✅ Ausgeführt |
|
| 0049 | entity_permissions Tabelle | ✅ |
|
||||||
| 0050 | owner_id auf 15 Tabellen | ✅ Ausgeführt |
|
| 0050 | owner_id auf 15 Tabellen | ✅ |
|
||||||
| 0051 | Folder ACLs → entity_permissions | ⬜ Geplant |
|
| 0051 | Folder ACLs → entity_permissions | ✅ |
|
||||||
|
| 0052 | RLS Policies auf contacts | ✅ |
|
||||||
|
|
||||||
## Git Commits
|
## Git Commits
|
||||||
| Hash | Beschreibung |
|
| Hash | Beschreibung |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| cc021cd | feat: folder permissions (ACLs) - share folders with users/groups |
|
| cc021cd | feat: folder permissions (ACLs) |
|
||||||
| 5afa1fa | sprint1: entity_permissions table + owned_mixin + universal permission service + API + migrations 0049+0050 |
|
| 5afa1fa | sprint1: entity_permissions + owned_mixin + service + API + migrations 0049+0050 |
|
||||||
|
| 48647a5 | sprint1: set_user_context + RLS policies + folder ACL migration 0051+0052 |
|
||||||
|
| ea1c1d5 | sprint1 complete: rate limiting on permission changes |
|
||||||
|
|
||||||
|
## Was in Produktion läuft
|
||||||
|
- entity_permissions Tabelle (universelle ACLs)
|
||||||
|
- owner_id auf 15 Tabellen (contacts, addresses, attachments, etc.)
|
||||||
|
- PostgreSQL RLS auf contacts (4 Policies: admin, owner, tenant-owned, shared)
|
||||||
|
- set_user_context() wird bei jedem Request gesetzt
|
||||||
|
- Universelle Permission API unter /api/v1/permissions/*
|
||||||
|
- Rate Limiting auf Permission-Änderungen
|
||||||
|
- Folder ACLs in entity_permissions migriert
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Visibility filter helper — applies row-level security to SQLAlchemy queries.
|
||||||
|
|
||||||
|
This is the core function that ALL routes use to filter queries based on
|
||||||
|
the current user's permissions. It works alongside PostgreSQL RLS as a
|
||||||
|
Defense-in-Depth layer.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.core.visibility import apply_visibility_filter
|
||||||
|
|
||||||
|
@router.get("/contacts")
|
||||||
|
async def list_contacts(db, current_user):
|
||||||
|
query = select(Contact).where(Contact.tenant_id == tenant_id)
|
||||||
|
query = await apply_visibility_filter(
|
||||||
|
db, query, "contact", Contact, user_id, tenant_id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
...
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- System admin → no filter (sees everything)
|
||||||
|
- Non-admin → filter by: owner_id = user OR owner_id IS NULL OR shared via entity_permissions
|
||||||
|
- Uses EXISTS subquery for performance (better than IN)
|
||||||
|
- Works with any entity type that has owner_id column
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import and_, exists, or_, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.models.entity_permission import EntityPermission
|
||||||
|
from app.models.group import UserGroup
|
||||||
|
from app.models.user import User, UserTenant
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Permission rank for comparison
|
||||||
|
_PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "delete": 4, "owner": 5}
|
||||||
|
|
||||||
|
|
||||||
|
def _rank(level: str) -> int:
|
||||||
|
return _PERM_RANK.get(level, 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_principals(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
) -> tuple[list[uuid.UUID], uuid.UUID | None]:
|
||||||
|
"""Get user's group IDs and role ID for permission resolution."""
|
||||||
|
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]
|
||||||
|
|
||||||
|
role_q = await db.execute(
|
||||||
|
select(UserTenant.role_id)
|
||||||
|
.where(UserTenant.user_id == user_id)
|
||||||
|
.where(UserTenant.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
role_id = role_q.scalar_one_or_none()
|
||||||
|
|
||||||
|
return group_ids, role_id
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_visibility_filter(
|
||||||
|
db: AsyncSession,
|
||||||
|
query: Any,
|
||||||
|
entity_type: str,
|
||||||
|
model: type[DeclarativeBase],
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
is_system_admin: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Apply row-level visibility filter to a SQLAlchemy query.
|
||||||
|
|
||||||
|
This function modifies the query to only return rows that the user
|
||||||
|
is allowed to see based on:
|
||||||
|
1. System admin → no filter (sees everything)
|
||||||
|
2. Owner → rows where owner_id = user_id
|
||||||
|
3. Tenant-owned → rows where owner_id IS NULL
|
||||||
|
4. Shared → rows with entity_permissions entry for this user/group/role
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
query: SQLAlchemy select() query to filter
|
||||||
|
entity_type: Entity type string (e.g. 'contact', 'address')
|
||||||
|
model: SQLAlchemy model class (must have owner_id column)
|
||||||
|
user_id: Current user's UUID
|
||||||
|
tenant_id: Current tenant's UUID
|
||||||
|
is_system_admin: Whether user is system admin
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified query with visibility filter applied
|
||||||
|
"""
|
||||||
|
if is_system_admin:
|
||||||
|
return query # System admin sees everything
|
||||||
|
|
||||||
|
# Get user's groups and role
|
||||||
|
group_ids, role_id = await _get_user_principals(db, user_id, tenant_id)
|
||||||
|
|
||||||
|
# Build principal conditions for entity_permissions EXISTS subquery
|
||||||
|
principal_conditions = [
|
||||||
|
and_(
|
||||||
|
EntityPermission.principal_type == "user",
|
||||||
|
EntityPermission.principal_id == user_id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if group_ids:
|
||||||
|
principal_conditions.append(
|
||||||
|
and_(
|
||||||
|
EntityPermission.principal_type == "group",
|
||||||
|
EntityPermission.principal_id.in_(group_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if role_id:
|
||||||
|
principal_conditions.append(
|
||||||
|
and_(
|
||||||
|
EntityPermission.principal_type == "role",
|
||||||
|
EntityPermission.principal_id == role_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build EXISTS subquery for shared entities
|
||||||
|
# Uses EXISTS instead of IN for better PostgreSQL optimization
|
||||||
|
shared_exists = (
|
||||||
|
select(EntityPermission.id)
|
||||||
|
.where(EntityPermission.entity_type == entity_type)
|
||||||
|
.where(EntityPermission.entity_id == model.id)
|
||||||
|
.where(EntityPermission.tenant_id == tenant_id)
|
||||||
|
.where(EntityPermission.permission_level != "none")
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
EntityPermission.expires_at.is_(None),
|
||||||
|
EntityPermission.expires_at > text("NOW()"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(or_(*principal_conditions))
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply filter: owner OR tenant-owned OR shared
|
||||||
|
visibility_condition = or_(
|
||||||
|
model.owner_id == user_id, # Own entities
|
||||||
|
model.owner_id.is_(None), # Tenant-owned entities
|
||||||
|
shared_exists, # Shared via entity_permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
return query.where(visibility_condition)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_single_entity_access(
|
||||||
|
db: AsyncSession,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
required_level: str = "read",
|
||||||
|
is_system_admin: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if user has at least the required access level on a single entity.
|
||||||
|
|
||||||
|
Used for GET/PUT/DELETE on individual entities.
|
||||||
|
"""
|
||||||
|
if is_system_admin:
|
||||||
|
return True
|
||||||
|
|
||||||
|
from app.services.entity_permission_service import get_effective_access
|
||||||
|
access = await get_effective_access(
|
||||||
|
db, tenant_id, user_id, entity_type, entity_id
|
||||||
|
)
|
||||||
|
return _rank(access) >= _rank(required_level)
|
||||||
|
|
||||||
|
|
||||||
|
async def filter_response_fields(
|
||||||
|
data: dict[str, Any],
|
||||||
|
field_permissions: dict[str, dict[str, str]],
|
||||||
|
module: str,
|
||||||
|
is_system_admin: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Filter response fields based on field-level permissions.
|
||||||
|
|
||||||
|
Removes fields marked as 'hidden', keeps others.
|
||||||
|
This is a convenience wrapper that can be used in any route.
|
||||||
|
"""
|
||||||
|
if is_system_admin:
|
||||||
|
return data
|
||||||
|
|
||||||
|
module_perms = field_permissions.get(module, {})
|
||||||
|
if not module_perms:
|
||||||
|
return data
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in data.items()
|
||||||
|
if module_perms.get(key, "read") != "hidden"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_visibility_filter_cached(
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: Any,
|
||||||
|
query: Any,
|
||||||
|
entity_type: str,
|
||||||
|
model: type[DeclarativeBase],
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
is_system_admin: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Apply visibility filter using cached visible IDs from Redis.
|
||||||
|
|
||||||
|
This is an alternative to apply_visibility_filter() that uses
|
||||||
|
pre-computed visible IDs from Redis cache for better performance.
|
||||||
|
|
||||||
|
Use this for list queries where you need maximum performance.
|
||||||
|
"""
|
||||||
|
if is_system_admin:
|
||||||
|
return query
|
||||||
|
|
||||||
|
from app.services.entity_permission_service import get_cached_visible_ids
|
||||||
|
|
||||||
|
visible_ids, _ = await get_cached_visible_ids(
|
||||||
|
db, redis, tenant_id, user_id, entity_type
|
||||||
|
)
|
||||||
|
|
||||||
|
if not visible_ids:
|
||||||
|
# No visible entities — return empty result
|
||||||
|
return query.where(text("1 = 0"))
|
||||||
|
|
||||||
|
return query.where(model.id.in_(list(visible_ids)))
|
||||||
+15
-2
@@ -69,12 +69,16 @@ async def list_contacts(
|
|||||||
):
|
):
|
||||||
"""List contacts with pagination, FTS search, type/folder filter, sorting."""
|
"""List contacts with pagination, FTS search, type/folder filter, sorting."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_admin = current_user.get("is_system_admin", False)
|
||||||
return await contact_service.list_contacts(
|
return await contact_service.list_contacts(
|
||||||
db, tenant_id,
|
db, tenant_id,
|
||||||
page=page, page_size=page_size, search=search,
|
page=page, page_size=page_size, search=search,
|
||||||
contact_type=type, folder_id=folder_id,
|
contact_type=type, folder_id=folder_id,
|
||||||
sort_by=sort_by, sort_order=sort_order,
|
sort_by=sort_by, sort_order=sort_order,
|
||||||
resolved_perms=current_user,
|
resolved_perms=current_user,
|
||||||
|
user_id=user_id,
|
||||||
|
is_system_admin=is_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +92,12 @@ async def export_contacts(
|
|||||||
):
|
):
|
||||||
"""Stream contacts as CSV."""
|
"""Stream contacts as CSV."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
csv_data = await contact_service.export_contacts_csv(db, tenant_id, contact_type=type, search=search)
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_admin = current_user.get("is_system_admin", False)
|
||||||
|
csv_data = await contact_service.export_contacts_csv(
|
||||||
|
db, tenant_id, contact_type=type, search=search,
|
||||||
|
user_id=user_id, is_system_admin=is_admin,
|
||||||
|
)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
io.StringIO(csv_data),
|
io.StringIO(csv_data),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
@@ -132,10 +141,14 @@ async def get_contact(
|
|||||||
):
|
):
|
||||||
"""Get a single contact with contact_persons."""
|
"""Get a single contact with contact_persons."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
is_admin = current_user.get("is_system_admin", False)
|
||||||
try:
|
try:
|
||||||
return await contact_service.get_contact(db, tenant_id, contact_id)
|
return await contact_service.get_contact(db, tenant_id, contact_id, user_id=user_id, is_system_admin=is_admin)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{contact_id}")
|
@router.put("/{contact_id}")
|
||||||
|
|||||||
@@ -145,13 +145,26 @@ async def list_contacts(
|
|||||||
sort_by: str = "displayname",
|
sort_by: str = "displayname",
|
||||||
sort_order: str = "asc",
|
sort_order: str = "asc",
|
||||||
resolved_perms: dict | None = None,
|
resolved_perms: dict | None = None,
|
||||||
|
user_id: uuid.UUID | None = None,
|
||||||
|
is_system_admin: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List contacts with pagination, FTS search, type/folder filter, sorting."""
|
"""List contacts with pagination, FTS search, type/folder filter, sorting.
|
||||||
|
|
||||||
|
Applies row-level visibility filter based on ownership and entity_permissions.
|
||||||
|
"""
|
||||||
|
from app.core.visibility import apply_visibility_filter
|
||||||
|
|
||||||
base = select(Contact).where(
|
base = select(Contact).where(
|
||||||
Contact.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
Contact.deleted_at.is_(None),
|
Contact.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply row-level visibility filter
|
||||||
|
if user_id and not is_system_admin:
|
||||||
|
base = await apply_visibility_filter(
|
||||||
|
db, base, "contact", Contact, user_id, tenant_id, is_system_admin
|
||||||
|
)
|
||||||
|
|
||||||
if contact_type:
|
if contact_type:
|
||||||
base = base.where(Contact.type == contact_type)
|
base = base.where(Contact.type == contact_type)
|
||||||
|
|
||||||
@@ -191,8 +204,9 @@ async def list_contacts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> dict:
|
async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str,
|
||||||
"""Get a single contact with contact_persons."""
|
user_id: uuid.UUID | None = None, is_system_admin: bool = False) -> dict:
|
||||||
|
"""Get a single contact with contact_persons. Checks row-level access."""
|
||||||
q = (
|
q = (
|
||||||
select(Contact)
|
select(Contact)
|
||||||
.options(selectinload(Contact.contact_persons))
|
.options(selectinload(Contact.contact_persons))
|
||||||
@@ -206,6 +220,16 @@ async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -
|
|||||||
contact = result.scalar_one_or_none()
|
contact = result.scalar_one_or_none()
|
||||||
if not contact:
|
if not contact:
|
||||||
raise ValueError("Contact not found")
|
raise ValueError("Contact not found")
|
||||||
|
|
||||||
|
# Check row-level access
|
||||||
|
if user_id and not is_system_admin:
|
||||||
|
from app.core.visibility import check_single_entity_access
|
||||||
|
has_access = await check_single_entity_access(
|
||||||
|
db, "contact", contact.id, user_id, tenant_id, "read", is_system_admin
|
||||||
|
)
|
||||||
|
if not has_access:
|
||||||
|
raise PermissionError("No access to this contact")
|
||||||
|
|
||||||
return _serialize_contact_detail(contact)
|
return _serialize_contact_detail(contact)
|
||||||
|
|
||||||
|
|
||||||
@@ -222,6 +246,7 @@ async def create_contact(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
updated_by=user_id,
|
updated_by=user_id,
|
||||||
|
owner_id=user_id,
|
||||||
**{k: v for k, v in data.items() if hasattr(Contact, k)},
|
**{k: v for k, v in data.items() if hasattr(Contact, k)},
|
||||||
)
|
)
|
||||||
db.add(contact)
|
db.add(contact)
|
||||||
@@ -277,9 +302,10 @@ async def create_contact(
|
|||||||
|
|
||||||
|
|
||||||
async def update_contact(
|
async def update_contact(
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict,
|
||||||
|
is_system_admin: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a contact."""
|
"""Update a contact. Checks row-level write access."""
|
||||||
# Expire all cached objects to ensure fresh data with selectinload
|
# Expire all cached objects to ensure fresh data with selectinload
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
q = (
|
q = (
|
||||||
@@ -296,6 +322,15 @@ async def update_contact(
|
|||||||
if not contact:
|
if not contact:
|
||||||
raise ValueError("Contact not found")
|
raise ValueError("Contact not found")
|
||||||
|
|
||||||
|
# Check row-level write access
|
||||||
|
if not is_system_admin:
|
||||||
|
from app.core.visibility import check_single_entity_access
|
||||||
|
has_access = await check_single_entity_access(
|
||||||
|
db, "contact", contact.id, user_id, tenant_id, "write", is_system_admin
|
||||||
|
)
|
||||||
|
if not has_access:
|
||||||
|
raise PermissionError("No write access to this contact")
|
||||||
|
|
||||||
# Hook: contact.before_update
|
# Hook: contact.before_update
|
||||||
await do_action("contact.before_update", data, db=db, tenant_id=tenant_id, user_id=user_id, contact_id=contact_id)
|
await do_action("contact.before_update", data, db=db, tenant_id=tenant_id, user_id=user_id, contact_id=contact_id)
|
||||||
|
|
||||||
@@ -356,9 +391,10 @@ async def update_contact(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_contact(
|
async def delete_contact(
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, user_id: uuid.UUID | None = None
|
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, user_id: uuid.UUID | None = None,
|
||||||
|
is_system_admin: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Soft-delete a contact."""
|
"""Soft-delete a contact. Checks row-level admin access."""
|
||||||
q = select(Contact).where(
|
q = select(Contact).where(
|
||||||
Contact.id == uuid.UUID(contact_id),
|
Contact.id == uuid.UUID(contact_id),
|
||||||
Contact.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
@@ -369,6 +405,15 @@ async def delete_contact(
|
|||||||
if not contact:
|
if not contact:
|
||||||
raise ValueError("Contact not found")
|
raise ValueError("Contact not found")
|
||||||
|
|
||||||
|
# Check row-level admin access
|
||||||
|
if not is_system_admin:
|
||||||
|
from app.core.visibility import check_single_entity_access
|
||||||
|
has_access = await check_single_entity_access(
|
||||||
|
db, "contact", contact.id, user_id, tenant_id, "admin", is_system_admin
|
||||||
|
)
|
||||||
|
if not has_access:
|
||||||
|
raise PermissionError("No admin access to this contact")
|
||||||
|
|
||||||
# Hook: contact.before_delete
|
# Hook: contact.before_delete
|
||||||
await do_action("contact.before_delete", db=db, tenant_id=tenant_id, contact_id=contact_id, user_id=user_id)
|
await do_action("contact.before_delete", db=db, tenant_id=tenant_id, contact_id=contact_id, user_id=user_id)
|
||||||
|
|
||||||
@@ -491,9 +536,12 @@ async def delete_contact_person(
|
|||||||
|
|
||||||
|
|
||||||
async def export_contacts_csv(
|
async def export_contacts_csv(
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, contact_type: str | None = None, search: str | None = None
|
db: AsyncSession, tenant_id: uuid.UUID, contact_type: str | None = None, search: str | None = None,
|
||||||
|
user_id: uuid.UUID | None = None, is_system_admin: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Export contacts as CSV string."""
|
"""Export contacts as CSV string. Only exports visible contacts."""
|
||||||
|
from app.core.visibility import apply_visibility_filter
|
||||||
|
|
||||||
base = select(Contact).where(
|
base = select(Contact).where(
|
||||||
Contact.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
Contact.deleted_at.is_(None),
|
Contact.deleted_at.is_(None),
|
||||||
@@ -502,6 +550,13 @@ async def export_contacts_csv(
|
|||||||
base = base.where(Contact.type == contact_type)
|
base = base.where(Contact.type == contact_type)
|
||||||
if search:
|
if search:
|
||||||
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
||||||
|
|
||||||
|
# Apply visibility filter
|
||||||
|
if user_id and not is_system_admin:
|
||||||
|
base = await apply_visibility_filter(
|
||||||
|
db, base, "contact", Contact, user_id, tenant_id, is_system_admin
|
||||||
|
)
|
||||||
|
|
||||||
base = base.order_by(Contact.displayname)
|
base = base.order_by(Contact.displayname)
|
||||||
|
|
||||||
result = await db.execute(base)
|
result = await db.execute(base)
|
||||||
|
|||||||
Reference in New Issue
Block a user