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
+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)