feat(permissions): ABAC integration, principals caching, cache version validation

- Integrate ABAC policies into apply_visibility_filter() (allow/deny with priority)
- Add field whitelist (ABAC_ALLOWED_FIELDS) for build_sql_condition() security
- Add request-level ContextVar for user principals (group_ids, role_id)
- Set principals in deps.py (session + bearer auth)
- Use ContextVar in visibility.py and permission_resolver.py (N+1 fix)
- Add version validation to get_cached_visible_ids() (cache strategy unification)
- Deactivate delegation route (parked — not integrated into resolve_permissions)
- Add 7 ABAC integration tests

All 70 tests pass (7 ABAC + 63 existing). No regressions.
This commit is contained in:
Agent Zero
2026-08-06 22:05:15 +02:00
parent 19ecc0cd71
commit 7c8f2a2222
8 changed files with 668 additions and 40 deletions
+31 -3
View File
@@ -96,20 +96,31 @@ async def get_current_user(
is_admin = session_data.get("is_system_admin", False)
await set_user_context(db, user_id, group_ids, is_admin)
# Check membership status (P1.7: suspended membership should not be usable)
# Check membership status and load role_id (P1.7: suspended membership should not be usable)
from app.models.user import UserTenant
membership_q = await db.execute(
select(UserTenant.status)
select(UserTenant.status, UserTenant.role_id)
.where(UserTenant.user_id == user_id)
.where(UserTenant.tenant_id == tenant_id)
)
membership_status = membership_q.scalar_one_or_none()
membership_row = membership_q.first()
membership_status = membership_row[0] if membership_row else None
role_id = membership_row[1] if membership_row else None
if membership_status is not None and membership_status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": f"Mitgliedschaft ist {membership_status}, Zugriff verweigert", "code": "membership_suspended"},
)
# Cache user principals for this request — avoids N+1 queries in visibility.py
from app.core.principals import UserPrincipals, set_principals
set_principals(UserPrincipals(
user_id=user_id,
tenant_id=tenant_id,
group_ids=group_ids,
role_id=role_id,
))
# Load resolved permissions from cache (or DB on miss)
from app.core.permissions import get_cached_permissions
@@ -165,6 +176,23 @@ async def get_current_user_bearer(
is_admin = user_data.get("is_system_admin", False)
await set_user_context(db, user_id, group_ids, is_admin)
# Load role_id and cache user principals for this request
from app.models.user import UserTenant
membership_q = await db.execute(
select(UserTenant.role_id)
.where(UserTenant.user_id == user_id)
.where(UserTenant.tenant_id == tenant_id)
)
role_id = membership_q.scalar_one_or_none()
from app.core.principals import UserPrincipals, set_principals
set_principals(UserPrincipals(
user_id=user_id,
tenant_id=tenant_id,
group_ids=group_ids,
role_id=role_id,
))
# Load resolved permissions
from app.core.permissions import get_cached_permissions
redis = get_redis()