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
+40 -26
View File
@@ -74,20 +74,27 @@ async def get_effective_access(
if owner_id == user_id:
return "owner"
# Get user's groups and role
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]
# Get user's groups and role — use ContextVar cache when available
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:
group_ids = cached.group_ids
role_id = cached.role_id
else:
# Fallback: load from DB (worker, tests, non-request context)
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()
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()
# Build principal conditions
principal_conditions = [
@@ -178,20 +185,27 @@ async def get_visible_ids(
all_ids = {row[0] for row in all_q}
return all_ids, {eid: "delete" for eid in all_ids}
# Get user's groups and role
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]
# Get user's groups and role — use ContextVar cache when available
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:
group_ids = cached.group_ids
role_id = cached.role_id
else:
# Fallback: load from DB (worker, tests, non-request context)
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()
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()
# 1. Owned entities
model = _get_entity_model(entity_type)