f2a7206c7d
Check Cross-Plugin Imports / check (push) Waiting to run
Vorher: _execute_tool (agent_loop.py) und der KI-Chat-Loop (stream_chat_comm) führten JEDES im Registry registrierte Tool aus, wenn das LLM dessen Namen lieferte — ohne Abgleich mit der angebotenen Liste, ohne required_permission-Check. Reproduktion (Astra): Nur audit_allowed angeboten, Modell nannte audit_restricted (system:admin) → Handler lief. Fix (fail-closed, an ALLEN Ausfuehrungspfaden): - _check_tool_access: (1) Allowlist — nur Tools die dem LLM angeboten wurden duerfen laufen; (2) required_permission gegen die AKTUELLEN User-Rechte (deny-first, Rechteentzug wirkt sofort, ohne Kontext = Ablehnung). Guard vor dry-run/approval/execute-Pfaden. - stream_chat_comm: gleicher Allowlist-Guard vor execute_tool_call. - run_react_loop/agent_runner/agent_stream/agent_routes reichen user_permissions durch (perm_ctx bzw. Session-User). - check_permission: Session-Kontexte tragen denied_permissions statt denied — beide Keys werden gelesen, Deny-Liste wird nie mehr ignoriert. Tests: test_agent_loop.py 18/18 (7 neue F01-Tests nach Astra-Abnahme: nicht angeboten → Handler null; fehlende Permission → abgewiesen; Fail-closed ohne Kontext; Deny-Liste session-shape; Rechteentzug mitten im Lauf wirkt auf naechste Aktion; dry-run guardet auch). ruff clean. Pre-existing-Beweis: permission_system_live-Failures reproduzieren sich ohne diesen Patch identisch (Plugin-Aktivierung in ephemeraler Test-DB, bekanntes Vorbestands-Finding).
505 lines
17 KiB
Python
505 lines
17 KiB
Python
"""Permission resolver — resolves effective permissions for a user+tenant.
|
||
|
||
Architecture:
|
||
- Permissions are NOT stored in the session.
|
||
- Redis cache: resolved:{user_id}:{tenant_id} with 5-min TTL.
|
||
- Permission-version stamping for immediate invalidation.
|
||
- Resolution: allowed = (role ∪ groups), denied = (role.denied ∪ groups.denied), resolved = allowed − denied.
|
||
- Wildcards: contacts:*, *:read, *:* (bare * is forbidden).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import uuid
|
||
from typing import Any
|
||
|
||
import redis.asyncio as aioredis
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.group import Group, UserGroup
|
||
from app.models.role import Role
|
||
from app.models.tenant import Tenant
|
||
from app.models.user import User, UserTenant
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
CACHE_TTL = 300 # 5 minutes
|
||
CACHE_PREFIX = "resolved"
|
||
|
||
# Central permission rank — single source of truth for permission level ordering.
|
||
# Used by entity_permission_service, bulk_permission_service, visibility, etc.
|
||
PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "delete": 4, "owner": 5}
|
||
|
||
# Severity ordering for field permissions: highest wins
|
||
_FIELD_PERM_SEVERITY = {"hidden": 3, "readonly": 2, "read": 1}
|
||
|
||
|
||
def _matches_permission(granted: str, required: str) -> bool:
|
||
"""Check if a granted permission matches the required permission.
|
||
|
||
Supports wildcards:
|
||
- contacts:read → exact match
|
||
- contacts:* → all actions for contacts
|
||
- *:read → all modules, read action
|
||
- *:* → everything (superadmin)
|
||
"""
|
||
if granted == required:
|
||
return True
|
||
g_parts = granted.split(":")
|
||
r_parts = required.split(":")
|
||
if len(g_parts) != len(r_parts):
|
||
return False
|
||
for i, g_part in enumerate(g_parts):
|
||
if g_part == "*":
|
||
continue
|
||
if g_part != r_parts[i]:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _permission_matches_any(granted_permissions: set[str], required: str) -> bool:
|
||
"""Check if any granted permission matches the required permission."""
|
||
for granted in granted_permissions:
|
||
if _matches_permission(granted, required):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _normalize_permissions(permissions: Any) -> set[str]:
|
||
"""Normalize permissions from JSONB to a set of strings.
|
||
|
||
Supports formats:
|
||
- list[str]: ["contacts:read", "contacts:write"]
|
||
- dict[str, bool]: {"contacts:read": true, "contacts:write": false}
|
||
- dict[str, dict]: {"contacts": {"read": true, "write": false}}
|
||
"""
|
||
result: set[str] = set()
|
||
if isinstance(permissions, list):
|
||
for p in permissions:
|
||
if isinstance(p, str):
|
||
result.add(p.replace(".", ":"))
|
||
elif isinstance(permissions, dict):
|
||
for key, val in permissions.items():
|
||
if isinstance(val, bool):
|
||
if val:
|
||
result.add(key.replace(".", ":"))
|
||
elif isinstance(val, dict):
|
||
for action, enabled in val.items():
|
||
if enabled:
|
||
result.add(f"{key}:{action}".replace(".", ":"))
|
||
return result
|
||
|
||
|
||
def _merge_field_permissions(
|
||
existing: dict[str, dict[str, str]],
|
||
incoming: dict[str, Any],
|
||
) -> None:
|
||
"""Merge incoming field permissions into existing dict.
|
||
|
||
Uses 'strictest right wins': hidden > readonly > read.
|
||
When a field already exists, the more restrictive (higher severity) value wins.
|
||
"""
|
||
for module, fields in incoming.items():
|
||
if not isinstance(fields, dict):
|
||
continue
|
||
if module not in existing:
|
||
existing[module] = {}
|
||
for field, perm in fields.items():
|
||
if not isinstance(perm, str):
|
||
continue
|
||
perm_lower = perm.lower()
|
||
if perm_lower not in _FIELD_PERM_SEVERITY:
|
||
# Unknown permission level — skip with warning
|
||
logger.warning(
|
||
"Unknown field permission level '%s' for %s.%s — skipping",
|
||
perm, module, field,
|
||
)
|
||
continue
|
||
current = existing[module].get(field)
|
||
if current is None:
|
||
existing[module][field] = perm_lower
|
||
else:
|
||
# Strictest (highest severity) wins
|
||
if _FIELD_PERM_SEVERITY[perm_lower] > _FIELD_PERM_SEVERITY.get(current, 0):
|
||
existing[module][field] = perm_lower
|
||
|
||
|
||
async def _get_current_permission_version(
|
||
db: AsyncSession,
|
||
user_id: uuid.UUID,
|
||
tenant_id: uuid.UUID,
|
||
) -> int:
|
||
"""Get the current max permission_version from DB for cache validation.
|
||
|
||
Uses a SAVEPOINT so that a failure here does not abort the outer transaction.
|
||
"""
|
||
async with db.begin_nested():
|
||
# Check role version
|
||
ut_q = select(UserTenant.role_id).where(
|
||
UserTenant.user_id == user_id,
|
||
UserTenant.tenant_id == tenant_id,
|
||
)
|
||
ut_result = await db.execute(ut_q)
|
||
role_id = ut_result.scalar_one_or_none()
|
||
|
||
max_version = 0
|
||
if role_id is not None:
|
||
role_q = select(Role.permission_version).where(Role.id == role_id)
|
||
role_result = await db.execute(role_q)
|
||
role_ver = role_result.scalar_one_or_none()
|
||
if role_ver is not None:
|
||
max_version = max(max_version, role_ver)
|
||
|
||
# Check group versions
|
||
ug_q = select(UserGroup.group_id).where(
|
||
UserGroup.user_id == user_id,
|
||
UserGroup.tenant_id == tenant_id,
|
||
)
|
||
ug_result = await db.execute(ug_q)
|
||
group_ids = [row[0] for row in ug_result.all()]
|
||
|
||
if group_ids:
|
||
groups_q = select(func.max(Group.permission_version)).where(
|
||
Group.id.in_(group_ids),
|
||
Group.deleted_at.is_(None),
|
||
)
|
||
groups_result = await db.execute(groups_q)
|
||
group_max = groups_result.scalar()
|
||
if group_max is not None:
|
||
max_version = max(max_version, group_max)
|
||
|
||
return max_version
|
||
|
||
|
||
async def resolve_permissions(
|
||
db: AsyncSession,
|
||
user_id: uuid.UUID,
|
||
tenant_id: uuid.UUID,
|
||
) -> dict[str, Any]:
|
||
"""Resolve effective permissions for a user within a tenant.
|
||
|
||
Returns:
|
||
{
|
||
"permissions": set[str], # allowed permissions
|
||
"denied": set[str], # explicitly denied
|
||
"field_permissions": dict, # {module: {field: hidden|readonly|read}}
|
||
"is_system_admin": bool,
|
||
"version": int, # permission_version for cache invalidation
|
||
}
|
||
"""
|
||
# Use SAVEPOINT for the initial query so a failure doesn't abort
|
||
# the outer transaction.
|
||
try:
|
||
async with db.begin_nested():
|
||
user_q = select(User.is_system_admin).where(User.id == user_id)
|
||
user_result = await db.execute(user_q)
|
||
is_system_admin = user_result.scalar() or False
|
||
except Exception:
|
||
logger.warning(
|
||
"SAVEPOINT failed for is_system_admin query (user=%s), retrying without savepoint",
|
||
user_id,
|
||
exc_info=True,
|
||
)
|
||
# Last-resort fallback: still use savepoint to isolate
|
||
async with db.begin_nested():
|
||
user_q = select(User.is_system_admin).where(User.id == user_id)
|
||
user_result = await db.execute(user_q)
|
||
is_system_admin = user_result.scalar() or False
|
||
|
||
if is_system_admin:
|
||
return {
|
||
"permissions": {"*:*"},
|
||
"denied": set(),
|
||
"field_permissions": {},
|
||
"is_system_admin": True,
|
||
"version": 0, # system admin doesn't need version tracking
|
||
}
|
||
|
||
# Load UserTenant to get role_id — use SAVEPOINT
|
||
async with db.begin_nested():
|
||
ut_q = select(UserTenant).where(
|
||
UserTenant.user_id == user_id,
|
||
UserTenant.tenant_id == tenant_id,
|
||
)
|
||
ut_result = await db.execute(ut_q)
|
||
user_tenant = ut_result.scalar_one_or_none()
|
||
|
||
allowed: set[str] = set()
|
||
denied: set[str] = set()
|
||
field_perms: dict[str, dict[str, str]] = {}
|
||
max_version = 0
|
||
|
||
# Load role permissions
|
||
if user_tenant and user_tenant.role_id:
|
||
async with db.begin_nested():
|
||
role_q = select(Role).where(Role.id == user_tenant.role_id)
|
||
role_result = await db.execute(role_q)
|
||
role = role_result.scalar_one_or_none()
|
||
|
||
if role:
|
||
allowed |= _normalize_permissions(role.permissions)
|
||
denied |= _normalize_permissions(role.denied_permissions)
|
||
max_version = max(max_version, role.permission_version or 0)
|
||
# Merge field permissions using strictest-wins
|
||
if role.field_permissions:
|
||
_merge_field_permissions(field_perms, role.field_permissions)
|
||
|
||
# ⚠️ Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben
|
||
# Built-in role strings (admin/editor/viewer) no longer grant permissions directly.
|
||
# All permissions must come through the Role-based RBAC system (role_id → Role.permissions).
|
||
# Migration 0112 creates Role records for existing users and links role_id.
|
||
|
||
# Load group permissions
|
||
async with db.begin_nested():
|
||
ug_q = select(UserGroup).where(
|
||
UserGroup.user_id == user_id,
|
||
UserGroup.tenant_id == tenant_id,
|
||
)
|
||
ug_result = await db.execute(ug_q)
|
||
user_groups = ug_result.scalars().all()
|
||
|
||
if user_groups:
|
||
group_ids = [ug.group_id for ug in user_groups]
|
||
async with db.begin_nested():
|
||
groups_q = select(Group).where(
|
||
Group.id.in_(group_ids),
|
||
Group.deleted_at.is_(None),
|
||
)
|
||
groups_result = await db.execute(groups_q)
|
||
groups = groups_result.scalars().all()
|
||
|
||
for group in groups:
|
||
allowed |= _normalize_permissions(group.permissions)
|
||
denied |= _normalize_permissions(group.denied_permissions)
|
||
max_version = max(max_version, group.permission_version or 0)
|
||
# Merge field permissions using strictest-wins
|
||
if group.field_permissions:
|
||
_merge_field_permissions(field_perms, group.field_permissions)
|
||
|
||
# Load tenant resolution strategy
|
||
async with db.begin_nested():
|
||
tenant_q = select(Tenant).where(Tenant.id == tenant_id)
|
||
tenant_result = await db.execute(tenant_q)
|
||
tenant = tenant_result.scalar_one_or_none()
|
||
resolution_strategy = tenant.resolution_strategy if tenant else "highest_wins"
|
||
|
||
# ⚠️ Only highest_wins strategy supported — other strategies removed as they were no-ops.
|
||
# All strategies previously produced the same result: resolved = allowed - denied.
|
||
# The strategy field is kept for backward compatibility but only highest_wins is honored.
|
||
resolved = allowed - denied
|
||
|
||
return {
|
||
"permissions": resolved,
|
||
"denied": denied,
|
||
"field_permissions": field_perms,
|
||
"is_system_admin": False,
|
||
"version": max_version,
|
||
"resolution_strategy": resolution_strategy,
|
||
}
|
||
|
||
|
||
async def get_cached_permissions(
|
||
db: AsyncSession,
|
||
redis: aioredis.Redis,
|
||
user_id: uuid.UUID,
|
||
tenant_id: uuid.UUID,
|
||
) -> dict[str, Any]:
|
||
"""Get resolved permissions from Redis cache or resolve from DB.
|
||
|
||
Validates the cached permission_version against the current DB version.
|
||
If they differ, the cache entry is stale and will be re-resolved.
|
||
|
||
Falls back to direct DB resolution when Redis is unavailable.
|
||
"""
|
||
cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}"
|
||
|
||
from app.core.resilience import get_circuit
|
||
|
||
circuit = get_circuit("redis")
|
||
redis_available = await circuit.can_proceed()
|
||
|
||
if redis_available:
|
||
try:
|
||
raw = await redis.get(cache_key)
|
||
await circuit.record_success()
|
||
if raw is not None:
|
||
data = json.loads(raw)
|
||
cached_version = data.get("version", -1)
|
||
|
||
# Validate cached version against current DB version
|
||
try:
|
||
current_version = await _get_current_permission_version(db, user_id, tenant_id)
|
||
except Exception:
|
||
logger.warning(
|
||
"Failed to query current permission_version for cache validation "
|
||
"(user=%s, tenant=%s) — invalidating cache and re-resolving",
|
||
user_id, tenant_id,
|
||
exc_info=True,
|
||
)
|
||
await redis.delete(cache_key)
|
||
# Fall through to re-resolution from DB (don't return None)
|
||
|
||
if cached_version == current_version:
|
||
return data
|
||
|
||
logger.info(
|
||
"Permission cache version mismatch for user=%s tenant=%s "
|
||
"(cached=%s, current=%s) — re-resolving",
|
||
user_id, tenant_id, cached_version, current_version,
|
||
)
|
||
await redis.delete(cache_key)
|
||
except Exception as exc:
|
||
logger.warning("Redis permission cache failed: %s — resolving from DB", exc)
|
||
await circuit.record_failure()
|
||
redis_available = False
|
||
|
||
# Cache miss, stale, or Redis unavailable — resolve from DB
|
||
resolved = await resolve_permissions(db, user_id, tenant_id)
|
||
|
||
cache_data = {
|
||
"permissions": list(resolved["permissions"]),
|
||
"denied": list(resolved["denied"]),
|
||
"field_permissions": resolved["field_permissions"],
|
||
"is_system_admin": resolved["is_system_admin"],
|
||
"version": resolved["version"],
|
||
}
|
||
|
||
# Try to cache (best-effort during Redis outage)
|
||
if redis_available:
|
||
try:
|
||
await redis.setex(cache_key, CACHE_TTL, json.dumps(cache_data))
|
||
except Exception:
|
||
logger.warning("Failed to cache permissions in Redis — continuing without cache")
|
||
|
||
return cache_data
|
||
|
||
|
||
async def invalidate_permission_cache(
|
||
redis: aioredis.Redis,
|
||
user_id: uuid.UUID,
|
||
tenant_id: uuid.UUID,
|
||
) -> None:
|
||
"""Invalidate the permission cache for a specific user+tenant."""
|
||
cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}"
|
||
await redis.delete(cache_key)
|
||
|
||
|
||
async def invalidate_all_user_permissions(
|
||
redis: aioredis.Redis,
|
||
tenant_id: uuid.UUID,
|
||
) -> None:
|
||
"""Invalidate permission cache for all users in a tenant (e.g. after role/group change).
|
||
|
||
Uses SCAN (non-blocking) instead of KEYS to avoid blocking Redis.
|
||
"""
|
||
pattern = f"{CACHE_PREFIX}:*:{tenant_id}"
|
||
batch_size = 200
|
||
cursor: int | bytes | str = 0
|
||
deleted_count = 0
|
||
|
||
while True:
|
||
cursor, keys = await redis.scan(
|
||
cursor=cursor,
|
||
match=pattern,
|
||
count=batch_size,
|
||
)
|
||
if keys:
|
||
await redis.delete(*keys)
|
||
deleted_count += len(keys)
|
||
# SCAN returns cursor as bytes or int depending on redis-py version
|
||
cursor_int = int(cursor) if cursor else 0
|
||
if cursor_int == 0:
|
||
break
|
||
|
||
logger.info(
|
||
"Invalidated %d permission cache entries for tenant=%s",
|
||
deleted_count, tenant_id,
|
||
)
|
||
|
||
|
||
def check_permission(resolved: dict[str, Any], required: str) -> bool:
|
||
"""Check if resolved permissions grant the required permission.
|
||
|
||
Args:
|
||
resolved: result from get_cached_permissions or resolve_permissions
|
||
required: permission string like "contacts:read"
|
||
"""
|
||
if resolved.get("is_system_admin"):
|
||
return True
|
||
|
||
permissions = set(resolved.get("permissions", []))
|
||
# F01/Astra: session user contexts carry ``denied_permissions`` while
|
||
# resolved permission dicts use ``denied`` — accept both so the deny
|
||
# list is never silently ignored.
|
||
denied = set(
|
||
resolved.get("denied", resolved.get("denied_permissions", [])) or []
|
||
)
|
||
|
||
# Check deny list first
|
||
for d in denied:
|
||
if _matches_permission(d, required):
|
||
return False
|
||
|
||
return _permission_matches_any(permissions, required)
|
||
|
||
|
||
def check_field_access(
|
||
resolved: dict[str, Any],
|
||
module: str,
|
||
field: str,
|
||
default: str = "read",
|
||
) -> str:
|
||
"""Check field-level access for a module+field.
|
||
|
||
Returns: "hidden", "readonly", or "read"
|
||
"""
|
||
if resolved.get("is_system_admin"):
|
||
return "read"
|
||
|
||
field_perms = resolved.get("field_permissions", {})
|
||
module_perms = field_perms.get(module, {})
|
||
return module_perms.get(field, default)
|
||
|
||
|
||
def filter_fields_by_permission(
|
||
data: dict[str, Any],
|
||
resolved: dict[str, Any],
|
||
module: str,
|
||
) -> dict[str, Any]:
|
||
"""Filter response fields based on field-level permissions.
|
||
|
||
Removes fields marked as "hidden", keeps others.
|
||
Also filters custom_fields (JSONB dict) entries that are marked as hidden.
|
||
"""
|
||
if resolved.get("is_system_admin"):
|
||
return data
|
||
|
||
field_perms = resolved.get("field_permissions", {})
|
||
module_perms = field_perms.get(module, {})
|
||
|
||
if not module_perms:
|
||
return data
|
||
|
||
result = {}
|
||
for key, value in data.items():
|
||
perm = module_perms.get(key)
|
||
if perm == "hidden":
|
||
continue
|
||
|
||
# Special handling for custom_fields JSONB dict
|
||
if key == "custom_fields" and isinstance(value, dict):
|
||
filtered_custom = {}
|
||
for cf_key, cf_value in value.items():
|
||
cf_perm = module_perms.get(cf_key)
|
||
if cf_perm == "hidden":
|
||
continue
|
||
filtered_custom[cf_key] = cf_value
|
||
result[key] = filtered_custom
|
||
else:
|
||
result[key] = value
|
||
|
||
return result
|