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:
+78
-2
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user