Security fixes: P0-P2 complete (22 fixes)

P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed
P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK
P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal

8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
+99 -49
View File
@@ -18,7 +18,11 @@ _UNSET: Any = object()
class UserService:
"""Handles user CRUD operations."""
"""Handles user CRUD operations.
All queries are tenant-scoped through the UserTenant association table.
User.email is globally unique; tenant membership and role live in UserTenant.
"""
async def list_users(
self,
@@ -31,25 +35,33 @@ class UserService:
"""List users in a tenant with pagination and search."""
offset = (page - 1) * page_size
q = select(User).where(User.tenant_id == tenant_id)
count_q = select(func.count()).select_from(User).where(User.tenant_id == tenant_id)
base = (
select(User, UserTenant)
.join(UserTenant, UserTenant.user_id == User.id)
.where(UserTenant.tenant_id == tenant_id)
)
count_q = (
select(func.count())
.select_from(UserTenant)
.where(UserTenant.tenant_id == tenant_id)
)
if search:
search_filter = or_(
User.name.ilike(f"%{search}%"),
User.email.ilike(f"%{search}%"),
)
q = q.where(search_filter)
count_q = count_q.where(search_filter)
base = base.where(search_filter)
count_q = count_q.join(User, User.id == UserTenant.user_id).where(search_filter)
total = (await db.execute(count_q)).scalar() or 0
q = q.offset(offset).limit(page_size).order_by(User.created_at.desc())
q = base.offset(offset).limit(page_size).order_by(User.created_at.desc())
result = await db.execute(q)
users = result.scalars().all()
rows = result.all()
return {
"items": [self._user_to_dict(u) for u in users],
"items": [self._user_to_dict(u, ut) for u, ut in rows],
"total": total,
"page": page,
"page_size": page_size,
@@ -60,11 +72,21 @@ class UserService:
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> User | None:
"""Get a single user by ID within tenant scope."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
) -> tuple[User, UserTenant] | None:
"""Get a single user by ID within tenant scope.
Returns (User, UserTenant) tuple or None.
"""
q = (
select(User, UserTenant)
.join(UserTenant, UserTenant.user_id == User.id)
.where(User.id == user_id, UserTenant.tenant_id == tenant_id)
)
result = await db.execute(q)
return result.scalar_one_or_none()
row = result.first()
if row is None:
return None
return row[0], row[1]
async def create_user(
self,
@@ -77,29 +99,27 @@ class UserService:
role_id: uuid.UUID | None = None,
is_active: bool = True,
) -> User:
"""Create a new user in a tenant.
"""Create a new user and add them to the specified tenant.
If role_id is provided it links the user to a custom Role record.
The legacy ``role`` string is kept for backward compatibility.
If role_id is provided it links the UserTenant to a custom Role record.
The ``role`` string is the built-in role (admin/editor/viewer).
"""
user = User(
tenant_id=tenant_id,
email=email,
name=name,
password_hash=hash_password(password),
role=role,
role_id=role_id,
is_active=is_active,
preferences={},
)
db.add(user)
await db.flush()
# Add user-tenant membership
# Add user-tenant membership with role
ut = UserTenant(
user_id=user.id,
tenant_id=tenant_id,
is_default=True,
role=role,
role_id=role_id,
)
db.add(ut)
@@ -122,35 +142,34 @@ class UserService:
email: str | None = None,
current_password: str | None = None,
new_password: str | None = None,
) -> User | None:
"""Update a user.
) -> tuple[User, UserTenant] | None:
"""Update a user and their tenant membership.
``role_id`` uses a sentinel to distinguish three states:
- ``_UNSET`` (default): leave the existing role_id unchanged
- ``None``: clear the FK (fall back to the legacy ``role`` string)
- ``None``: clear the FK (fall back to the built-in ``role`` string)
- ``uuid.UUID``: link to a custom Role record
Returns (User, UserTenant) tuple or None if not found.
"""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
q = (
select(User, UserTenant)
.join(UserTenant, UserTenant.user_id == User.id)
.where(User.id == user_id, UserTenant.tenant_id == tenant_id)
)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
row = result.first()
if row is None:
return None
user, user_tenant = row[0], row[1]
if name is not None:
user.name = name
if role is not None:
user.role = role
user_tenant.role = role
if role_id is not _UNSET:
user.role_id = role_id
# Sync UserTenant.role_id so resolve_permissions picks up the change
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()
if user_tenant:
user_tenant.role_id = role_id
user_tenant.role_id = role_id
if is_active is not None:
user.is_active = is_active
if first_name is not None:
@@ -170,7 +189,7 @@ class UserService:
user.password_hash = hash_password(new_password)
await db.flush()
return user
return user, user_tenant
async def delete_user(
self,
@@ -178,28 +197,59 @@ class UserService:
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Delete a user from a tenant."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
"""Remove a user from a tenant (delete UserTenant membership).
If this is the user's only tenant membership, the User record is
also deleted. Otherwise only the UserTenant row is removed.
"""
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()
if user_tenant is None:
return False
await db.delete(user)
# Count total tenant memberships for this user
count_q = select(func.count()).select_from(UserTenant).where(
UserTenant.user_id == user_id
)
count_result = await db.execute(count_q)
membership_count = count_result.scalar() or 0
await db.delete(user_tenant)
if membership_count <= 1:
# User's only tenant — delete the User record too
user_q = select(User).where(User.id == user_id)
user_result = await db.execute(user_q)
user = user_result.scalar_one_or_none()
if user is not None:
await db.delete(user)
await db.flush()
return True
def _user_to_dict(self, user: User) -> dict[str, Any]:
"""Convert user to response dict."""
return {
def _user_to_dict(
self, user: User, user_tenant: UserTenant | None = None
) -> dict[str, Any]:
"""Convert user + user_tenant to response dict."""
result: dict[str, Any] = {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"role_id": str(user.role_id) if user.role_id else None,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
if user_tenant is not None:
result["role"] = user_tenant.role
result["role_id"] = str(user_tenant.role_id) if user_tenant.role_id else None
result["tenant_id"] = str(user_tenant.tenant_id)
else:
result["role"] = "viewer"
result["role_id"] = None
result["tenant_id"] = None
return result
user_service = UserService()