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
+78 -2
View File
@@ -29,7 +29,7 @@ import logging
import uuid
from typing import Any
from sqlalchemy import and_, exists, or_, select, text
from sqlalchemy import and_, exists, not_, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase
@@ -51,7 +51,17 @@ async def _get_user_principals(
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."""
"""Get user's group IDs and role ID for permission resolution.
Uses request-level ContextVar cache when available (set in deps.py).
Falls back to DB query when ContextVar is not set (e.g. worker, tests).
"""
from app.core.principals import get_principals
cached = get_principals()
if cached is not None and cached.user_id == user_id and cached.tenant_id == tenant_id:
return cached.group_ids, cached.role_id
# Fallback: load from DB (worker, tests, non-request context)
groups_q = await db.execute(
select(UserGroup.group_id)
.where(UserGroup.user_id == user_id)
@@ -157,6 +167,72 @@ async def apply_visibility_filter(
shared_exists, # Shared via entity_permissions
)
# ─── ABAC Policy Layer ──────────────────────────────────────────
# Load enabled entity policies for this entity_type + tenant and
# apply them as an additional visibility layer:
# (owner OR tenant-owned OR shared OR allow-matched) AND NOT (deny-matched)
from app.models.entity_policy import EntityPolicy
from app.services.policy_service import build_sql_condition
policy_stmt = (
select(EntityPolicy)
.where(EntityPolicy.tenant_id == tenant_id)
.where(EntityPolicy.entity_type == entity_type)
.where(EntityPolicy.enabled == True) # noqa: E712
.order_by(EntityPolicy.priority.desc())
)
policy_result = await db.execute(policy_stmt)
all_policies = policy_result.scalars().all()
# Filter policies applicable to this user (user / group / role principals)
applicable_policies: list[EntityPolicy] = []
for policy in all_policies:
if policy.principal_type == "user" and policy.principal_id == user_id:
applicable_policies.append(policy)
elif policy.principal_type == "group" and policy.principal_id in group_ids:
applicable_policies.append(policy)
elif (
policy.principal_type == "role"
and role_id is not None
and policy.principal_id == role_id
):
applicable_policies.append(policy)
if applicable_policies:
allow_clauses: list[Any] = []
deny_clauses: list[Any] = []
for policy in applicable_policies:
if not policy.conditions:
# Policy without conditions matches all rows
if policy.effect == "allow":
allow_clauses.append(text("1 = 1"))
else:
deny_clauses.append(text("1 = 1"))
continue
condition = build_sql_condition(
policy.conditions, model, entity_type=entity_type
)
if condition is None:
continue
if policy.effect == "allow":
allow_clauses.append(condition)
else:
deny_clauses.append(condition)
# allow: OR-join — expands visibility beyond owner/shared
if allow_clauses:
visibility_condition = or_(visibility_condition, *allow_clauses)
# deny: NOT — excludes matching rows (deny takes precedence)
if deny_clauses:
visibility_condition = and_(
visibility_condition,
not_(or_(*deny_clauses)),
)
return query.where(visibility_condition)