P0+P1 fixes: RCE sandbox, SQL injection, RLS tenant isolation, DB roles, test syntax, attachment, permission registry, membership check
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-07-29 12:28:08 +02:00
parent 81ae5b7cb6
commit 26bf8d3a31
12 changed files with 423 additions and 39 deletions
+11 -2
View File
@@ -99,10 +99,19 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def set_tenant_context(session: AsyncSession, tenant_id: uuid.UUID | str) -> None:
"""Set PostgreSQL session variable for RLS tenant context."""
"""Set PostgreSQL session variable for RLS tenant context.
Sets both app.current_tenant_id (new standard) and app.tenant_id
(legacy, used by migration 0044 policies) for backward compatibility.
"""
tid = str(tenant_id)
await session.execute(
text("SELECT set_config('app.current_tenant_id', :tid, true)"),
{"tid": str(tenant_id)},
{"tid": tid},
)
await session.execute(
text("SELECT set_config('app.tenant_id', :tid, true)"),
{"tid": tid},
)
+17 -3
View File
@@ -128,17 +128,31 @@ class PermissionRegistry:
self._core_field_definitions: list[dict[str, str]] = list(CORE_FIELD_DEFINITIONS)
def initialize(self, active_plugin_names: set[str] | None = None) -> None:
"""Build the registry from core permissions and active plugin manifests."""
"""Build the registry from core permissions and active plugin manifests.
Preserves already-registered plugin permissions (fixes P1.3 bug where
initialize() would wipe plugin permissions registered before startup).
"""
# Preserve existing plugin permissions
existing_plugin_perms = self._plugin_permissions.copy()
# Reset only core permissions, keep plugin permissions
self._permissions = {}
self._plugin_permissions = {}
self._active_plugins = active_plugin_names or set()
# Register core permissions
for perm in CORE_PERMISSIONS:
self._permissions[perm["key"]] = perm
# Re-apply plugin permissions that were registered before initialize()
for plugin_name, perms in existing_plugin_perms.items():
self._plugin_permissions[plugin_name] = perms
for entry in perms:
self._permissions[entry["key"]] = entry
self._initialized = True
logger.info("Permission registry initialized with %d core permissions", len(CORE_PERMISSIONS))
logger.info("Permission registry initialized with %d core permissions, %d plugin permissions",
len(CORE_PERMISSIONS), len(existing_plugin_perms))
def register_plugin_permissions(self, plugin_name: str, permissions: list[str]) -> None:
"""Register permissions from a plugin manifest."""
+14
View File
@@ -127,6 +127,20 @@ 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)
from app.models.user import UserTenant
membership_q = await db.execute(
select(UserTenant.status)
.where(UserTenant.user_id == user_id)
.where(UserTenant.tenant_id == tenant_id)
)
membership_status = membership_q.scalar_one_or_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"},
)
# Load resolved permissions from cache (or DB on miss)
from app.core.permissions import get_cached_permissions
@@ -8,7 +8,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
from jinja2 import Environment, FileSystemLoader, select_autoescape, StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
# ─── Constants ──────────────────────────────────────────────────────────────
@@ -63,14 +64,17 @@ PRESET_META: list[dict[str, Any]] = [
# ─── Jinja2 Environment ─────────────────────────────────────────────────────
def _get_env() -> Environment:
"""Create a Jinja2 environment with file system loader for templates dir."""
return Environment(
def _get_env() -> SandboxedEnvironment:
"""Create a sandboxed Jinja2 environment with file system loader for templates dir."""
env = SandboxedEnvironment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=select_autoescape(["html", "htm", "j2", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
undefined=StrictUndefined,
)
env.globals.clear()
return env
# ─── Public API ─────────────────────────────────────────────────────────────
@@ -111,11 +115,13 @@ def render_template_string(template_content: str, data: dict[str, Any]) -> str:
Returns:
Rendered HTML string
"""
env = Environment(
env = SandboxedEnvironment(
autoescape=select_autoescape(["html", "htm", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
undefined=StrictUndefined,
)
env.globals.clear()
template = env.from_string(template_content)
if "generated_at" not in data:
data["generated_at"] = datetime.now(timezone.utc).strftime(
@@ -77,10 +77,12 @@ def _report_to_response(r: ReportInstance) -> dict:
def _render_jinja2(template_content: str, data: dict) -> str:
"""Render a Jinja2 template string with the given data."""
from jinja2 import Environment, StrictUndefined
"""Render a Jinja2 template string with the given data (sandboxed)."""
from jinja2 import StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
env = Environment(autoescape=False, undefined=StrictUndefined)
env = SandboxedEnvironment(autoescape=True, undefined=StrictUndefined)
env.globals.clear()
template = env.from_string(template_content)
return template.render(**data)
+10
View File
@@ -47,8 +47,18 @@ async def save_attachment(
filename: str,
file_content: bytes,
mime_type: str,
is_system_admin: bool = False,
) -> dict[str, Any]:
"""Save a file to storage and create an Attachment record."""
# Check access on parent entity
if not is_system_admin:
from app.core.visibility import check_single_entity_access
has_access = await check_single_entity_access(
db, entity_type, entity_id, user_id, tenant_id, "write", is_system_admin
)
if not has_access:
raise PermissionError(f"No write access to {entity_type} {entity_id}")
# Generate unique filename and relative storage path
unique_filename = _generate_unique_filename(filename)
file_path = f"{entity_type}/{entity_id}/{unique_filename}"
+86 -23
View File
@@ -27,9 +27,70 @@ from app.models.entity_permission import EntityPermission
from app.models.group import Group, UserGroup
from app.models.role import Role
from app.models.user import User, UserTenant
from app.models.contact import Contact
from app.models.address import Address
from app.models.attachment import Attachment
from app.models.bank_account import BankAccount
from app.models.workflow import Workflow
from app.models.sequence import Sequence
from app.models.saved_filter import SavedFilter
from app.models.saved_view import SavedView
from app.models.webhook import Webhook
from app.models.notification import Notification
from app.models.custom_field_definition import CustomFieldDefinition
from app.models.contact_folder import ContactFolder
logger = logging.getLogger(__name__)
# ── Entity Model Registry ────────────────────────────────────────────────────
# Maps entity_type string to SQLAlchemy model class.
# This replaces insecure text(f"SELECT ... FROM {entity_type}s") queries
# with safe SQLAlchemy model-based queries (prevents SQL injection).
ENTITY_MODELS: dict[str, type] = {
"contact": Contact,
"address": Address,
"attachment": Attachment,
"bank_account": BankAccount,
"workflow": Workflow,
"sequence": Sequence,
"saved_filter": SavedFilter,
"saved_view": SavedView,
"webhook": Webhook,
"notification": Notification,
"custom_field_definition": CustomFieldDefinition,
"contact_folder": ContactFolder,
}
# Try to add plugin models if available
try:
from app.plugins.builtins.dms.models import DmsFile
ENTITY_MODELS["dms_file"] = DmsFile
except ImportError:
pass
try:
from app.plugins.builtins.calendar.models import CalendarEvent
ENTITY_MODELS["calendar_event"] = CalendarEvent
except ImportError:
pass
try:
from app.plugins.builtins.tasks.models import Task
ENTITY_MODELS["task"] = Task
except ImportError:
pass
try:
from app.plugins.builtins.mail.models import Mailbox
ENTITY_MODELS["mailbox"] = Mailbox
except ImportError:
pass
def _get_entity_model(entity_type: str) -> type:
"""Get SQLAlchemy model class for entity_type, or raise ValueError."""
model = ENTITY_MODELS.get(entity_type)
if model is None:
raise ValueError(f"Unknown entity type: {entity_type}")
return model
# Permission hierarchy: higher = more access
_PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "delete": 4, "owner": 5}
@@ -350,11 +411,10 @@ async def get_effective_access(
if user_q.scalar():
return "delete"
# Check ownership — load the entity's owner_id
# We use raw SQL to avoid importing every model
# Check ownership — load the entity's owner_id via SQLAlchemy model (safe from SQL injection)
model = _get_entity_model(entity_type)
owner_q = await db.execute(
text(f"SELECT owner_id FROM {entity_type}s WHERE id = :eid AND tenant_id = :tid"),
{"eid": entity_id, "tid": tenant_id},
select(model.owner_id).where(model.id == entity_id).where(model.tenant_id == tenant_id)
)
owner_row = owner_q.first()
if not owner_row:
@@ -460,10 +520,11 @@ async def get_visible_ids(
)
if user_q.scalar():
# Return all entity IDs
all_q = await db.execute(
text(f"SELECT id FROM {entity_type}s WHERE tenant_id = :tid AND deleted_at IS NULL"),
{"tid": tenant_id},
)
model = _get_entity_model(entity_type)
admin_q = select(model.id).where(model.tenant_id == tenant_id)
if hasattr(model, 'deleted_at'):
admin_q = admin_q.where(model.deleted_at.is_(None))
all_q = await db.execute(admin_q)
all_ids = {row[0] for row in all_q}
return all_ids, {eid: "delete" for eid in all_ids}
@@ -483,10 +544,11 @@ async def get_visible_ids(
role_id = role_q.scalar_one_or_none()
# 1. Owned entities
owned_q = await db.execute(
text(f"SELECT id FROM {entity_type}s WHERE tenant_id = :tid AND owner_id = :uid AND deleted_at IS NULL"),
{"tid": tenant_id, "uid": user_id},
)
model = _get_entity_model(entity_type)
owned_q_builder = select(model.id).where(model.tenant_id == tenant_id).where(model.owner_id == user_id)
if hasattr(model, 'deleted_at'):
owned_q_builder = owned_q_builder.where(model.deleted_at.is_(None))
owned_q = await db.execute(owned_q_builder)
visible: set[uuid.UUID] = set()
access_map: dict[uuid.UUID, str] = {}
for (eid,) in owned_q:
@@ -494,10 +556,10 @@ async def get_visible_ids(
access_map[eid] = "owner"
# 2. Tenant-owned entities (owner_id IS NULL)
tenant_owned_q = await db.execute(
text(f"SELECT id FROM {entity_type}s WHERE tenant_id = :tid AND owner_id IS NULL AND deleted_at IS NULL"),
{"tid": tenant_id},
)
tenant_q_builder = select(model.id).where(model.tenant_id == tenant_id).where(model.owner_id.is_(None))
if hasattr(model, 'deleted_at'):
tenant_q_builder = tenant_q_builder.where(model.deleted_at.is_(None))
tenant_owned_q = await db.execute(tenant_q_builder)
for (eid,) in tenant_owned_q:
if eid not in visible:
visible.add(eid)
@@ -591,16 +653,17 @@ async def batch_get_effective_access(
result: dict[uuid.UUID, str] = {}
# 1. Check ownership
owner_q = await db.execute(
text(f"SELECT id, owner_id FROM {entity_type}s WHERE id = ANY(:ids) AND tenant_id = :tid AND deleted_at IS NULL"),
{"ids": [str(eid) for eid in entity_ids], "tid": tenant_id},
)
# 1. Check ownership via SQLAlchemy model (safe from SQL injection)
model = _get_entity_model(entity_type)
batch_q = select(model.id, model.owner_id).where(model.id.in_(entity_ids)).where(model.tenant_id == tenant_id)
if hasattr(model, 'deleted_at'):
batch_q = batch_q.where(model.deleted_at.is_(None))
owner_q = await db.execute(batch_q)
for eid, owner_id in owner_q:
if owner_id == user_id:
result[uuid.UUID(str(eid))] = "owner"
result[eid] = "owner"
elif owner_id is None:
result[uuid.UUID(str(eid))] = "read"
result[eid] = "read"
# 2. Check permissions
now = datetime.now(UTC)